Customizing zudo-doc
The minimal→extend escalation ladder — from a one-line config field to full source control, in the order you reach for them.
A scaffolded zudo-doc project is intentionally minimal: one config file, your content, a stylesheet, and two thin route stubs (see Installation). Everything else — layout, chrome, schema, tokens — ships from @takazudo/zudo-doc and is consumed from node_modules.
That "one config file" ideal covers the common cases. This page is the escalation ladder for when it does not: each rung is more invasive than the last, and you should climb only as far as the change actually requires. The rungs are ordered by how soon a real project tends to hit them.
The ladder
| Rung | Reach for it when | Cost |
|---|---|---|
1. buildDocsSchema override | You need a custom, validated frontmatter key | Replace one function; own the schema |
| 2. Config fields | Toggling a feature or tuning behavior | None — it is just a field |
| 3. Token overrides | Re-theming colors, spacing, typography | A @theme block in global.css |
4. zudo-doc eject | A component's markup itself must change | You own the ejected copy |
5. chromeBindingsModule | Injecting host content/callables into injected routes | One host module |
6. Your own pages/*.tsx | A whole route must be yours | You own that route |
| 7. Deploy path | Going live (especially with SSR features) | Adapter + wrangler.toml + secrets |
| 8. Restoring pre-push / HTML checks | You want the showcase's validation gates | Copy a script + add deps |
Rung 1 — Custom frontmatter keys (buildDocsSchema)
This is the first place the one-config ideal breaks, so it leads the ladder.
Every other feature is a field on zudoDoc(). Frontmatter validation is not: the docs collection is validated by a Zod schema, and there is no config field that says "also allow a tier key." The schema is a function you replace — the buildDocsSchema escape hatch.
By default zudoDoc() builds the package schema for you (governance-aware, derived from tagGovernance + tagVocabularyEntries). To add your own key, import the default builder, extend its result, and hand the new builder back:
import { defineConfig } from "zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";
import { buildDocsSchema } from "@takazudo/zudo-doc/docs-schema";
import { z } from "zod";
export default defineConfig(
zudoDoc({
siteName: "My Docs",
// Replace the schema builder entirely. Start from the package default,
// then extend it with your own validated frontmatter keys.
buildDocsSchema: () =>
buildDocsSchema({ tagGovernance: "off" }).extend({
tier: z.enum(["core", "opt-in"]).optional(),
reviewed_by: z.string().optional(),
}),
}),
);Now pages can declare tier: core in frontmatter and it validates at build time. Combine this with docContentHeaderExtras (rung 5) to actually render a badge from that key.
Note
buildDocsSchema is one of the non-serializable escape-hatch fields on ZudoDocConfig — alongside colorSchemes, translations, directives, and tagVocabularyEntries. They travel the import graph (never JSON-serialized), which is exactly why they can carry functions and Zod types. See Configuration.
Rung 2 — Config fields
Almost everything else is a field. zudoDoc() defaults every setting, so your zfb.config.ts lists only what differs:
export default defineConfig(
zudoDoc({
siteName: "My Docs",
docHistory: true,
sidebarToggle: true,
tocMaxDepth: 3,
headerNav: [
{ label: "Guides", path: "/docs/guides", categoryMatch: "guides" },
],
footer: { copyright: "© 2026 Me" },
}),
);Reach for it when: you are turning a feature on/off or tuning behavior (TOC depth, navigation, color mode, locales).
Limit: config fields control what exists and how it behaves, not the pixel-level rendering of a component. For that, climb to rung 3 or 4.
The full field list — with the default for every one — is the Configuration reference, which mirrors the ZudoDocConfig type's own JSDoc.
Rung 3 — Token overrides
The scaffold's src/ is a short @import chain that pulls the package's theme.css (all the @theme design tokens), content.css, and features.css — followed by an empty @theme { … } block reserved for you. Because it comes after the package imports, anything you redefine there wins the cascade:
/* ...package @imports above... */
@theme {
--color-accent: oklch(0.6 0.2 250);
--font-sans: "Inter", system-ui, sans-serif;
--z-index-modal: 200; /* override a single default z-index tier */
}For per-component rebranding without touching every element that reads a global token, the package also exposes --zdc-* component tokens (heading font, prose font, card radius, TOC width, …). Set them once in :root.
Reach for it when: the change is a re-theme — colors, spacing, typography, radii.
Limit: tokens restyle; they do not restructure markup. See Design System, Color, and Component Tokens.
Rung 4 — Ejecting a component
When no token can reach the change — the markup itself is wrong for you — eject the component. Ejection copies its source out of the package into your project so you own the file directly:
# eject a component (run via your package manager's bin runner)
pnpm exec zudo-doc eject header
# → copies source into your project and rewrites imports to resolve locally
# see the full list
pnpm exec zudo-doc --helpThe 18 ejectable components:
header, footer, breadcrumb, toc, sidebar, theme-toggle, page-loading, tab-item, doc-pager, content-admonition, code-group, details, sidebar-tree-island, sidebar-toggle-island, desktop-sidebar-toggle-island, image-enlarge, doc-history, site-tree-nav-island
After ejecting, edit the local copy — changes take effect immediately under pnpm dev.
Note
Eject writes provenance into a .zudo-doc.json file recording which components you own. That file is lazy-created on the first eject — a fresh scaffold does not ship it, so an un-ejected project never carries an unexplained config file. Remove a component's directory and its .zudo-doc.json entry to go back to the package version.
Limit: an ejected copy no longer receives package updates for that component — you take on its maintenance.
Rung 5 — chromeBindingsModule (host-callables)
Under packageOwnedRoutes: true (the default) the doc chrome is wired inside the package, so there is no host file to render project-specific content into. chromeBindingsModule is the seam that gives it back: point it at a host module that exports a chromeBindings object, and the injected routes pick up your slots — a content-header renderer, home-hero extras, custom frontmatter-preview renderers, a footer tag loader, and more.
export default defineConfig(
zudoDoc({
siteName: "My Docs",
chromeBindingsModule: "./src/chrome-bindings.tsx",
}),
);import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
export const chromeBindings = defineChromeBindings({
docContentHeaderExtras: ({ entry }) =>
entry.data.tier === "core" ? <span class="...">Core</span> : null,
});The Design Token Panel has a parallel channel, designTokenPanelConfigModule: the package-default panel works with zero config (just designTokenPanel: true), and to fully customize it you point that setting at a host module exporting buildDesignTokenPanelConfig(mode). Both channels are documented in full — including their loud missing-file behavior — on Host Chrome Bindings.
Rung 6 — Your own pages/*.tsx
If a whole route needs to be yours, add the .tsx file to pages/ at the matching path. A project-owned page always wins over the package-injected route for the same path — the host file shadows the injection. This is how you replace the home page, add an entirely custom landing route, or take over a specific doc path.
The two scaffold stubs (pages/, pages/docs/[[...slug]].tsx) are themselves ordinary host pages using this precedence; you can expand them or add siblings. See Routing Conventions for how routes are enumerated and shadowed.
Rung 7 — The deploy path (an explicit extend step)
The minimal scaffold builds a pure static site — pnpm build emits dist/, which you can drop on any static host. It ships no adapter and no wrangler.toml by design. Going beyond a static export is a deliberate extend step:
Add a deploy adapter. For Cloudflare Workers, set the
adaptershell field:zfb.config.tszudoDoc({ siteName: "My Docs", adapter: "@takazudo/zfb-adapter-cloudflare", });An adapter is required for any route that opts out of prerendering (
prerender = false) — which is exactly what the AI assistant endpoint does.Add
wrangler.toml. Declare the Worker name, the custom domain route, and any bindings. This file does not exist in a fresh scaffold — you author it when you decide to deploy.Wire the AI assistant, if you enable it.
aiAssistant: truemounts an SSR/route. It ships in demo mode by default (api/ ai- chat aiChatDemoModedefaults tofalsein config, but the scaffold's chat is a static demo until you flip it to a live backend). For a real, Claude-backed assistant you must:set
aiChatDemoMode: false;create a
RATE_LIMITKV namespace (wrangler kv namespace create RATE_LIMIT) and bind it inwrangler.toml;add the
ANTHROPIC_API_KEYsecret (wrangler secret put ANTHROPIC_API_KEY);set
aiChatAllowedOriginsto your site origin.
See the Deployment guide and the AI Assistant API reference for the full runbook (secrets, KV, CORS, rate limiting).
Rung 8 — Restoring pre-push and HTML validation
The minimal scaffold drops the validation gates the showcase runs — they were dead weight for a small doc set. If your project grows to want them back, add them explicitly:
Pre-push validation (
b4push). The scaffold no longer shipsscripts/or arun- b4push. sh b4pushpackage script. To restore a before-push suite, add your own script (format → typecheck → build → link check) and wire it as apackage.jsonscript. The showcase'sscripts/is a reference implementation.run- b4push. sh HTML validation. The generated
package.jsonno longer includes thecheck:html/html-validatestep (nor.htmlvalidate.json). To validate built HTML, addhtml-validateas a dev dependency, restore a.htmlvalidate.json, and add acheck:htmlscript that runs it overdist/.
Note
The gen:z-index / check:z-index codegen was likewise removed from generated projects — the 13 default z-index tiers now ship unconditionally from @takazudo/, so a project only re-adds that codegen if it maintains a custom tier set. Overriding a single tier is a one-line @theme change (rung 3), not a codegen concern.
See also
Configuration — every
zudoDoc()field and its defaultHost Chrome Bindings —
chromeBindingsModuleanddesignTokenPanelConfigModulein depthcreate-zudo-doc CLI — what the scaffold emits
Migrating from v3 — moving an existing generated project onto this shape