handoff.config.* Resolution
How Handoff locates and resolves a workspace's config file, the full config shape, and the hooking architecture.
Resolution order
Handoff looks for exactly one config file in the workspace root (process.cwd(), or the
directory passed via -c/--config), in this precedence order:
handoff.config.tshandoff.config.jshandoff.config.cjshandoff.config.json
The first one found wins; if more than one exists, Handoff logs a warning naming which file it
used and which it ignored. .ts files are compiled in-memory with esbuild (bundled, CommonJS
output, handoff-app itself marked external) before being evaluated, no build step or ts-node
required. Whatever the file exports (module.exports = {...}, export default {...}, or a call
to the defineConfig() helper) is shallow-merged over Handoff's built-in defaults.
import { defineConfig } from 'handoff-app';
export default defineConfig({
figmaProjectId: '0gKWw8gYChpItKWzh8o23N',
app: { title: 'My Design System', client: 'Acme Co.' },
});defineConfig() is optional, it just normalizes camelCase keys (figmaProjectId,
googleTagManager, …) to the legacy snake_case ones the runtime reads internally
(figma_project_id, google_tag_manager, …), so either casing works in a TypeScript config.
The config shape
The full Config type (src/types/config.ts) is large; the pieces every workspace touches:
| Key | Purpose |
|---|---|
figma_project_id / figmaProjectId | Figma file key fetch reads from. Can also come from HANDOFF_FIGMA_PROJECT_ID. |
app | Site-level settings: title, client, theme, breakpoints, type_sort/color_sort/component_sort, ports. This block is what push:all pushes to POST /api/registry/config. |
entries.components / entries.patterns | Directories to scan for component/pattern declarations, see Directory conventions. |
brands | { sharedCss, entries: [{ brand, filePath }] } — parse hand-authored brand CSS custom properties into DTCG token files during tokens:build (see Token pipeline). |
validation | { validators: [...], failOn } — the current component-validation framework (axe/schema/contrast factories or custom validators). Supersedes the deprecated hooks.validateComponent. |
pipeline.transformers | Extra build-pipeline transformers (see Hooks below). |
assets_zip_links | Download links shown for the icons/logos zip exports. |
projectProfile / project_profile | Declared but unread. The key exists on the Config type and nothing in the codebase reads it; MCP project hydration is driven entirely by the HANDOFF_PROJECT_NAME / HANDOFF_DEFAULT_STACK_PROFILE env vars, see MCP → Route & transport. |
camelCase only works through `defineConfig()`
Older workspaces (module.exports = {...} with snake_case keys, e.g. figma_project_id) and
newer ones (defineConfig({...}) with camelCase, e.g. figmaProjectId) both work, but the
normalization lives in defineConfig(), not in the loader: normalizeConfig's only caller
is defineConfig itself, and the loader spreads the config file's exports verbatim. A config
that uses camelCase keys without wrapping them in defineConfig() silently leaves
figma_project_id null. New projects scaffolded by handoff-app init use the defineConfig /
camelCase form.
Hooks
config.hooks is Handoff's extensibility surface, the workspace can tap the build pipeline at
several points without forking handoff-app. All hooks are plain functions in
handoff.config.*:
| Hook | Runs | Signature |
|---|---|---|
validateComponent | Per component build (deprecated — use config.validation instead; still auto-adapted into a validator for back-compat) | (component) => Promise<Record<string, ValidationResult>> |
ssrBuildConfig | Before the esbuild pass that server-renders a React component for preview | (config: esbuild.BuildOptions) => BuildOptions |
clientBuildConfig | Before the client-side esbuild bundle for a preview | (config: esbuild.BuildOptions) => BuildOptions |
jsBuildConfig | Before the Vite build for the main JS entry/component scripts | (config: ViteInlineConfig) => InlineConfig |
cssBuildConfig | Before the Vite build for Sass/CSS compilation | (config: ViteInlineConfig) => InlineConfig |
htmlBuildConfig | Before the Vite build used for Handlebars/HTML preview rendering | (config: ViteInlineConfig) => InlineConfig |
getSchemaFromExports | When Handoff needs to find a component's property schema in its module exports | (exports) => schema |
schemaToProperties | When converting a raw schema into Handoff's slot/property metadata | (schema) => Record<string, SlotMetadata> |
registerHandlebarsHelpers | Once per preview render, after Handoff registers its built-in field/eq helpers | ({ handlebars, componentId, properties, injectFieldWrappers }) => void |
middleware | Wraps or replaces the built-in Next.js middleware (admin JWT gate + public paths) | (request, defaultProxy) => Promise<NextResponse> |
module.exports = {
hooks: {
cssBuildConfig: (viteConfig) => {
viteConfig.css.preprocessorOptions.scss.loadPaths = ['./styles'];
return viteConfig;
},
registerHandlebarsHelpers: ({ handlebars, componentId }) => {
handlebars.registerHelper('upperId', () => componentId.toUpperCase());
},
},
};There's a second, separate extensibility surface at the data layer:
config.pipeline.transformers lets a workspace register a custom IHandoffTransformer that runs
after tokens/components are extracted from Figma but before they're written to disk, useful for
emitting an extra output format (e.g. a flattened JSON for a mobile app) alongside the standard
CSS/SCSS/Tailwind/DTCG outputs.
middleware is implemented via a small bundled middleware-hook.mjs written into the
materialized app directory at init time, change the hook and restart dev/start to pick up
edits.
Notable env vars
| Variable | Purpose |
|---|---|
HANDOFF_WORKING_PATH | Absolute path to the workspace directory the CLI operates on. Set by dev/start and most commands at startup. |
HANDOFF_APP_ROOT | Absolute path to the Next.js app root. Set unconditionally by next.config.mjs for every mode, not just the legacy materialization path. |
HANDOFF_MODULE_PATH | Path to the handoff-app package itself, exposed to the running app. It is not what resolves .ts config files — the config loader derives that path from its own file location. |
HANDOFF_FIGMA_PROJECT_ID | Fallback for figma_project_id when not set in the config file. |
HANDOFF_DEV_ACCESS_TOKEN | Fallback for dev_access_token. |
HANDOFF_OUTPUT_DIR / HANDOFF_SITES_DIR | Fallbacks for exportsOutputDirectory / sitesOutputDirectory. |
app block fields pushed via push:all land in the registry's singleton config row and are read
back by every registry deployment through getDataProvider(), see
API → /api/registry.