Workspace

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:

  1. handoff.config.ts
  2. handoff.config.js
  3. handoff.config.cjs
  4. handoff.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.

handoff.config.ts
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:

KeyPurpose
figma_project_id / figmaProjectIdFigma file key fetch reads from. Can also come from HANDOFF_FIGMA_PROJECT_ID.
appSite-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.patternsDirectories 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.transformersExtra build-pipeline transformers (see Hooks below).
assets_zip_linksDownload links shown for the icons/logos zip exports.
projectProfile / project_profileDeclared 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.*:

HookRunsSignature
validateComponentPer component build (deprecated — use config.validation instead; still auto-adapted into a validator for back-compat)(component) => Promise<Record<string, ValidationResult>>
ssrBuildConfigBefore the esbuild pass that server-renders a React component for preview(config: esbuild.BuildOptions) => BuildOptions
clientBuildConfigBefore the client-side esbuild bundle for a preview(config: esbuild.BuildOptions) => BuildOptions
jsBuildConfigBefore the Vite build for the main JS entry/component scripts(config: ViteInlineConfig) => InlineConfig
cssBuildConfigBefore the Vite build for Sass/CSS compilation(config: ViteInlineConfig) => InlineConfig
htmlBuildConfigBefore the Vite build used for Handlebars/HTML preview rendering(config: ViteInlineConfig) => InlineConfig
getSchemaFromExportsWhen Handoff needs to find a component's property schema in its module exports(exports) => schema
schemaToPropertiesWhen converting a raw schema into Handoff's slot/property metadata(schema) => Record<string, SlotMetadata>
registerHandlebarsHelpersOnce per preview render, after Handoff registers its built-in field/eq helpers({ handlebars, componentId, properties, injectFieldWrappers }) => void
middlewareWraps or replaces the built-in Next.js middleware (admin JWT gate + public paths)(request, defaultProxy) => Promise<NextResponse>
handoff.config.js
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

VariablePurpose
HANDOFF_WORKING_PATHAbsolute path to the workspace directory the CLI operates on. Set by dev/start and most commands at startup.
HANDOFF_APP_ROOTAbsolute path to the Next.js app root. Set unconditionally by next.config.mjs for every mode, not just the legacy materialization path.
HANDOFF_MODULE_PATHPath 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_IDFallback for figma_project_id when not set in the config file.
HANDOFF_DEV_ACCESS_TOKENFallback for dev_access_token.
HANDOFF_OUTPUT_DIR / HANDOFF_SITES_DIRFallbacks 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.

On this page