tempo-sdk
v0.0.14
Published
Tempo SDK — Vite, Next.js, and Expo plugins for JSX annotation and shared page/storyboard types
Readme
tempo-sdk
Tempo's JSX-annotation plugins for Vite and Next.js. At build time, every JSX
element in your codebase gets data-tempo-* attributes that map the rendered
DOM back to the exact source-file location that produced it. Tempo's design
surface uses these attributes to navigate from a clicked element to its source.
This package ships two plugins from one workspace:
tempo-sdk— Vite plugin (tempoVitePlugin)tempo-sdk/nextjs— Next.js plugin (tempoNextjsPlugin)tempo-sdk/nextjs/plugin— default-exported Next.js plugin for ESM config filestempo-sdk/expo/plugin— Expo Babel plugin (tempoExpoBabelPlugin)
Both produce byte-identical data-tempo-* output, so downstream consumers
(source-editor.ts, frame-preload.ts) work the same regardless of which
framework the user's project is on.
Next.js usage
1. Install
In a workspace package:
{
"dependencies": { "tempo-sdk": "workspace:*" }
}In an external project: pnpm add tempo-sdk (or your package manager's equivalent).
2. Wrap next.config
CommonJS (next.config.js):
const tempoNextjsPlugin = require("tempo-sdk/nextjs");
module.exports = tempoNextjsPlugin()({
reactStrictMode: true,
// ...rest of your existing config
});ESM (next.config.mjs / next.config.ts):
import tempoNextjsPlugin from "tempo-sdk/nextjs/plugin";
export default tempoNextjsPlugin()({
reactStrictMode: true,
});You can also use the named export from tempo-sdk/nextjs:
import { tempoNextjsPlugin } from "tempo-sdk/nextjs";3. Set the TEMPO env var when launching dev
TEMPO=true next dev # Turbopack (Next.js 16+ default)
TEMPO=true next dev --webpack # Webpack modeWithout TEMPO set, the plugin is a complete no-op. You can leave
tempoNextjsPlugin() in next.config.js permanently — there is zero overhead
in normal dev. Tempo's start script sets TEMPO=true automatically when
launching the project under Tempo.
4. App Router — that's it
The loader auto-injects <meta name="tempo-project-root"> into the first JSX
<head> it sees (typically app/layout.tsx). Every JSX element in your project
gets data-tempo-filepath, data-tempo-position, data-tempo-modifiedts, and
data-tempo-supports-* attributes baked into the SWC-compiled output.
Example rendered HTML:
<button
data-tempo-filepath="components/Button.tsx"
data-tempo-position="142"
data-tempo-modifiedts="1708900000000"
data-tempo-supports-children="true"
>
Click me
</button>
<head>
<meta name="tempo-project-root" content="/abs/path/to/project" />
</head>5. Pages Router — one extra line in _document.tsx
<Head> from next/document is a custom React component (capital H), not
intrinsic JSX <head> (lowercase), so auto-inject doesn't fire there. Add the
project-root meta manually:
// pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document";
import { tempoProjectRoot } from "tempo-sdk/nextjs";
export default function Document() {
return (
<Html>
<Head>
<meta name="tempo-project-root" content={tempoProjectRoot()} />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}tempoProjectRoot() returns the same canonical root the loader uses for
data-tempo-filepath, so paths reconstruct correctly.
Options
All optional:
tempoNextjsPlugin({
autoInjectRootMeta: false, // disable head meta auto-inject
productionBuild: true, // run in `next build` too (rare)
computeAnnotatedPropsModule: "./tempo-strategy.js", // custom prop-computation
})(nextConfig);computeAnnotatedPropsModule is a path (not a function) because Turbopack
requires loader options to be JSON-serializable. The module's default export
must be a function (info, filepath, modifiedtsMs) => Record<string, PropValue>.
Composing with other wrappers (@next/mdx, etc.)
tempoNextjsPlugin follows the @next/mdx higher-order-config pattern, so it
composes naturally:
const tempoNextjsPlugin = require("tempo-sdk/nextjs");
const withMDX = require("@next/mdx")();
module.exports = tempoNextjsPlugin()(
withMDX({ reactStrictMode: true })
);tempoNextjsPlugin should be the outermost wrapper so its webpack/turbopack
chaining sees the inner wrappers' contributions.
Supported Next.js versions
| Next.js | Webpack | Turbopack | Notes |
|---|---|---|---|
| 16.x | --webpack flag | default | Validated end-to-end |
| 15.3 – 15.x | default | --turbo flag | Wrapper writes to turbopack.* |
| 13.0 – 15.2 | default | --turbo flag | Wrapper writes to experimental.turbo.* |
| 12.x | default | n/a | Webpack only; turbopack field omitted |
| < 12 | n/a | n/a | Out of scope |
The wrapper detects the version via next/package.json and writes to the
correct field automatically.
Vite usage
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tempoVitePlugin } from "tempo-sdk";
export default defineConfig({
plugins: [tempoVitePlugin(), react()],
});Same TEMPO env-var gating, same data-tempo-* output as the Next.js plugin.
The Vite plugin additionally exposes an /__tempo/errors SSE endpoint for
streaming compile errors to the design surface — see SPEC.md for the full
design.
Expo usage
Expo support is for Tempo's web canvas runtime. It annotates Expo web output for selection and editing; it does not make native-device canvases editable.
Configure the Tempo-owned Expo sidecar app's Babel config:
// tempo/babel.config.js
const path = require("node:path");
const { tempoExpoBabelPlugin } = require("tempo-sdk/expo/plugin");
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
[
tempoExpoBabelPlugin,
{
root: path.resolve(__dirname, ".."),
},
],
],
};
};If the Expo app uses NativeWind-style className, pass
reactNativeClassName: true in the plugin options.
The sidecar Metro config must watch the parent project so tempo/ can import
real app components:
// tempo/metro.config.js
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "..");
const sidecarNodeModules = path.join(projectRoot, "node_modules");
const config = getDefaultConfig(projectRoot);
const sidecarModule = (moduleName) =>
path.join(sidecarNodeModules, ...moduleName.split("/"));
const singletonModules = {
react: sidecarModule("react"),
"react-dom": sidecarModule("react-dom"),
"react-native": sidecarModule("react-native"),
"react-native-web": sidecarModule("react-native-web"),
"react/jsx-runtime": sidecarModule("react/jsx-runtime.js"),
"react/jsx-dev-runtime": sidecarModule("react/jsx-dev-runtime.js"),
};
config.watchFolders = Array.from(
new Set([...(config.watchFolders ?? []), workspaceRoot]),
);
config.resolver.nodeModulesPaths = Array.from(
new Set([
sidecarNodeModules,
path.join(workspaceRoot, "node_modules"),
...(config.resolver.nodeModulesPaths ?? []),
]),
);
config.resolver.disableHierarchicalLookup = true;
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
...singletonModules,
};
config.resolver.alias = {
...(config.resolver.alias ?? {}),
...singletonModules,
};
module.exports = config;Those singleton aliases are important for sidecar Expo projects. Without them,
Metro can resolve React Native files imported from the parent app against the
parent app's node_modules while the sidecar resolves its own runtime against
tempo/node_modules, which can produce duplicate React instances and invalid
hook-call failures.
Launch Expo web with TEMPO=true:
TEMPO=true npx expo start --webTempo discovers the printed local URL and probes /tempo-host; no fixed port is
required.
How it works
The Next.js plugin is a webpack-compatible loader (nextjs-loader.cjs) plus a
higher-order config wrapper (tempoNextjsPlugin) that registers the loader for
both Webpack and Turbopack from a single user-facing entrypoint.
The flow per source file:
- Resource gating —
.tsx/.jsxonly, excludenode_modules, requireprocess.env.TEMPO. - Read mtime via
fs.statSync. - Compute project-relative filepath via
path.relative(rootContext, …). - Call
annotateFilefrom@modules/annotationwith a callback that returns thedata-tempo-*props for each JSX element. The walker visits everyJSXElement, fragments are skipped, and props are inserted as JSX attribute strings at the end of each opening tag. - If the file contains an intrinsic JSX
<head>, splice<meta name="tempo-project-root" content="…" />into the post-annotation source. The injected meta does not itself carrydata-tempo-*attrs (it has no source location), which keeps every other element'sdata-tempo-positionpointing at the byte offset of<in the on-disk source file. - Return the annotated source. Errors at any step cause the loader to return
the original source unchanged —
correct or untouched, always.
The tempoNextjsPlugin wrapper:
- Adds an
enforce: "pre"rule underconfig.module.rulesfor Webpack (/.(tsx|jsx)$/). - Adds a
*.{tsx,jsx}rule undernextConfig.turbopack.rulesfor Turbopack withcondition: { all: [{ not: "foreign" }, "development"] }(excludes node_modules / Next internals AND production builds). - Chains an existing user-supplied
webpack(config, options)function and merges with any existingturbopack.rulesso other plugins are preserved. - No-ops entirely when
process.env.TEMPOis not set.
See SPEC_NEXTJS.md for the complete design specification.
Demo
pnpm --filter @tempo-modules/tempo-sdk-nextjs-demo dev # Turbopack on :3477
pnpm --filter @tempo-modules/tempo-sdk-nextjs-demo dev:webpack # Webpack on :3478Then:
curl -s localhost:3477/ | grep -oE 'data-tempo-filepath="[^"]+"' | sort -uShould print one line per source file rendered on the home page.
Tests
pnpm test # all 308 tests across both plugins
pnpm test:nextjs # 175 Next.js tests (~24s)
pnpm test:nextjs:unit # fast unit tests, no servers
pnpm test:nextjs:integration # live `next dev` against demo (Turbopack + Webpack)
pnpm test:nextjs:production # `next build` correctness (no annotations in prod)Test layers:
| File | Tests | Coverage |
|---|---|---|
| nextjs-loader.test.ts | 41 | Loader function — file filtering, default strategy, auto-inject, error handling |
| nextjs-wrapper.test.ts | 26 | tempoNextjsPlugin — webpack chaining, turbopack merging, version detection, options |
| nextjs-fixtures.test.ts | 40 | Project shapes — App/Pages Router, server/client components, all routing types |
| nextjs-published.test.ts | 14 | Built dist/*.cjs and dist/*.js — CJS callable, ESM imports, parity |
| nextjs-production.test.ts | 6 | next build produces zero data-tempo-* attributes |
| nextjs-e2e-correctness.test.ts | — | Live next dev against demo (both bundlers); every recorded data-tempo-position actually points at < in the on-disk source file |
Related docs
SPEC.md— Vite plugin specificationSPEC_NEXTJS.md— Next.js plugin specificationSPEC_TSC.md— TypeScript type-analyzer (used by both plugins fordata-tempo-supports-*)
