When a project sets `packageOwnedRoutes: true`, `@takazudo/zudo-doc` injects the doc routes itself and the project ships an almost-empty `pages/` directory. That is convenient, but it removes the host files where you would normally hang project-specific rendering. **Host chrome bindings** are the seam that gives it back: a small, typed set of slots (`ChromeHostBindings`) that let the host inject its own content and callables into the package-owned chrome without ejecting anything.

This page covers named header-right components, the `docContentHeaderExtras` and `homeExtras` content seams, and the delivery channel that carries every binding into injected and self-contained routes: `settings.chromeBindingsModule`.

[Dynamic content unavailable in text export]

These seams matter mainly under `packageOwnedRoutes: true`. A project that still ships its own `pages/*.tsx` stubs already has host files to render into and can call `createChrome(context, hostBindings)` directly. See [Routing Conventions](https://zudo-doc.takazudomodular.com/docs/develop/routing-conventions#package-route-injection-and-shadowing) for how package-owned routes are enumerated.



## The `ChromeHostBindings` type

Every seam described here is a field on the `ChromeHostBindings` interface, exported from `@takazudo/zudo-doc/factory-context`:

```ts
import type { ChromeHostBindings } from "@takazudo/zudo-doc/factory-context";
```

All fields are optional. Any slot you omit falls back to the package default that reproduces the pre-seam behavior byte-for-byte, so a partial bindings object is always safe.

`ChromeHostBindings` types every slot with the wide structural shape the chrome's own call sites need internally. Assigning a real, narrowly-typed component or callable to it directly — or reaching for an `as`/`as unknown as` cast to make the assignment compile — erases the one check that matters: whether the value you provide actually accepts the props the chrome passes it. Build bindings with `defineChromeBindings` instead, exported from `@takazudo/zudo-doc/chrome-bindings`: its input type, `ChromeBindingsInput`, declares the exact props or args each slot's real call site passes, so a component that requires a prop the chrome never provides is a compile error (the drift-detection check from #2674), and the wide `ChromeHostBindings` shape is produced by one internal widening step you never have to reason about.

## The delivery channel: `chromeBindingsModule`

Under `packageOwnedRoutes: true` the chrome is wired inside the package, so there is no host call site to pass bindings into. The route context that the plugin hands to the routes carries **serializable data only** (settings, translations, tag vocabulary) — it cannot carry functions or components. The channel works around this: instead of serializing the callables, the host serializes a **path** to a module that exports them.

Set `chromeBindingsModule` in your `zudoDoc()` config to a project-root-relative path pointing at a module with a named `chromeBindings` export built with `defineChromeBindings`:

```ts title="zfb.config.ts"
import { defineConfig } from "zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";

export default defineConfig(
  zudoDoc({
    // packageOwnedRoutes defaults to true
    chromeBindingsModule: "./src/chrome-bindings.tsx",
  }),
);
```

```tsx title="src/chrome-bindings.tsx"
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";

export const chromeBindings = defineChromeBindings({
  // ...seams go here (see below)
});
```

At build time the routes plugin registers a virtual module that re-exports your `chromeBindings`, and the injected chrome shim spreads it into the chrome factory. A string path is serializable, so the "route context is data only" rule still holds — only the loader source differs.

[Dynamic content unavailable in text export]

Fresh scaffold document-route stubs import `virtual:zudo-doc-chrome-bindings` and pass the result to `createChrome`, so the same module reaches both self-contained and injected routes. If you author a route from scratch, pass `chromeBindings` as the second `createChrome(routeCtx, chromeBindings)` argument too. The [global `mdxExtras` registration pattern](https://zudo-doc.takazudomodular.com/docs/guides/custom-components#global-registration-mdxextras) shows that route seam in context.



## Named header-right components

`headerRightItems` stays serializable: a custom component item carries only its string name. The callable renderer lives in `chromeBindings.headerRightComponents` and is resolved during build/SSR. Here is the complete configuration:

```ts title="zfb.config.ts"
import { defineConfig } from "zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";

export default defineConfig(
  zudoDoc({
    siteName: "Acme Docs",
    chromeBindingsModule: "./src/chrome-bindings.tsx",
    headerRightItems: [
      { type: "link", href: "/status", label: "Status" },
      { type: "component", component: "release-badge" },
      { type: "component", component: "search" },
    ],
  }),
);
```

```tsx title="src/chrome-bindings.tsx"
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";

export const chromeBindings = defineChromeBindings({
  headerRightComponents: {
    "release-badge": ({ item, index, lang }) => (
      <a
        href={`/${lang ?? "en"}/releases`}
        data-component={item.component}
        data-position={index}
      >
        v4
      </a>
    ),
  },
});
```

The renderer receives the exact header-right render context: the serialized `item`, its zero-based `index`, locale, GitHub values, built-in child slots, and the color-mode/locale gates. Registry values must be functions. The built-in names (`theme-toggle`, `language-switcher`, `version-switcher`, `github-link`, and `search`) are reserved and cannot be shadowed. A reserved duplicate or unknown configured name fails with the offending registry/settings location and a remediation message instead of silently omitting markup.

Only the item name travels in the route-context data. `headerRightComponents` remains in the host-callables module and is never serialized. As with other bindings-module components, treat these renderers as SSR-presentational unless they also have a supported static island-registration path.

### Preserving session-scoped island props

By default, same-locale navigation refreshes the serialized props of islands nested under the persisted header from the incoming page. Keep that behavior for page-derived props. If a statically registered host island instead owns session-scoped props — for example, a badge whose initial count must not be replaced by the next page's SSR snapshot — place `data-zd-props-preserve` on the island's root element or an ancestor inside the persisted header:

```tsx
<div data-zd-props-preserve>
  <SessionBadge initialCount={unreadCount} />
</div>
```

The live DOM governs the opt-out; adding the attribute only to the incoming page does not preserve the current island. A preserved island receives neither an updated `data-props` value nor a remount flag. Placing `data-zd-props-preserve` on the persisted header itself is a blanket opt-out for every nested island, so prefer the narrowest boundary that owns the session-scoped props.

### It un-strands the pre-existing slots too

`docContentHeaderExtras` and `homeExtras` are new, but `ChromeHostBindings` already had several slots that had **no way to reach injected routes** before this channel existed — they silently stayed at their stub defaults. `chromeBindingsModule` un-strands all of them at once:

| Slot | Default when omitted |
| --- | --- |
| `Header` | Package `HeaderWithDefaults`; receives `HeaderSlotProps` |
| `Footer` | Package `FooterWithDefaults`; receives `FooterSlotProps` |
| `Sidebar` | Package `SidebarWithDefaults`; receives `SidebarSlotProps` |
| `Toc` | Package desktop `Toc`; receives `TocSlotProps` |
| `Breadcrumb` | Package `Breadcrumb`; receives `BreadcrumbSlotProps` |
| `DocPager` | Package `DocPager`; receives `DocPagerSlotProps` |
| `SearchWidget` | Package search widget, with the site base bound internally |
| `headerRightComponents` | `{}` — only package built-in header-right names resolve |
| `docHistoryMeta` | `{}` — no Created/Updated block |
| `sidebarsConfig` | `{}` — auto-generated tree only |
| `frontmatterRenderers` | `{}` — no custom [frontmatter preview](https://zudo-doc.takazudomodular.com/docs/reference/frontmatter-preview) renderers |
| `buildFrontmatterPreviewEntries` | `() => []` — the preview table never renders |
| `loadTagsForLocale` | `() => []` — no footer tag entries |
| `tagVocabulary` | `[]` — no footer tag vocabulary |
| `BodyEndIslands` | Package island subset derived from settings |
| `DocHistory` | No-op stub — renders nothing |
| `DesignTokenPanelBootstrap` | Package fixed-target server boundary; it mounts when `designTokenPanel: true`, with no host wiring. This slot only replaces which builder mounts while the package panel is ON — it stays unmounted with `designTokenPanel: false` regardless of what you pass here. To mount your OWN panel with the package panel off, call `runDesignTokenPanelBootstrapOnce` from your own island (e.g. wired through the `BodyEndIslands` slot below) and set `bundleZdtp: true`, or the real loader stays shadowed by a throwing stub. See [Mounting your own panel](https://zudo-doc.takazudomodular.com/docs/reference/design-token-panel#implementation-mounting-your-own-panel). |
| `mdxExtras` | Package SSR components (`Details`, `HtmlPreview`, `Island`) plus a no-op `PresetGenerator` |
| `docContentHeaderExtras` | Absent — nothing renders |
| `homeExtras` | Absent — nothing renders |

So the same module you create for `docContentHeaderExtras` is also where you would, for example, register the custom frontmatter renderers that previously only worked on host-owned routes.

### `sidebarNodes` is a `createHeaderWithDefaults` prop, not a `Header` binding slot

The `Header` row above replaces the whole component: your replacement receives `HeaderSlotProps` and owns its own mobile-drawer tree entirely. `sidebarNodes` is a different, narrower seam — an optional prop on `HeaderWithDefaultsProps` that overrides only the tree the *package's* header factory passes to `SidebarToggle`, without replacing the rest of the header. It is available on `createHeaderWithDefaults(ctx)` and on `createChrome(...).HeaderWithDefaults`; it is **not** part of `HeaderSlotProps`, so a `chromeBindingsModule`-configured `Header` replacement never receives it. See `createHeaderWithDefaults(ctx)` in `API.md` for the full contract.

Generated and package-owned document routes are one deliberate exception to
normal object precedence: when `docHistory` is enabled, their statically
imported package `DocHistory` is overlaid after the configured object. That
keeps its client-island registration scanner-reachable while preserving every
other binding. A `DocHistory` value declared only in the virtual host module
cannot replace it on those routes.

[Dynamic content unavailable in text export]

**SSR-presentational contract only.** A client island defined *inside* the bindings module is not guaranteed to hydrate on injected routes — the virtual re-export sits outside zfb's static-import scanner reachability graph. Use the bindings module for server-rendered content and callables. If you need a hydrating island on an injected route, it still needs a statically-imported registration path.



[Dynamic content unavailable in text export]

**Missing-file and empty-string behavior are both loud.** If `chromeBindingsModule` is set but the resolved file does not exist, the build fails at plugin setup with an error naming the resolved absolute path — never a silent empty fallback. If `chromeBindingsModule` is set to an empty or blank string, the build also fails at plugin setup, naming the setting. When the setting is absent (not set at all), the channel emits `export const chromeBindings = {}` and behavior is byte-identical to omitting it.



## `docContentHeaderExtras` — content-header injection

`docContentHeaderExtras` renders extra content in the doc-content header, **between the page `<h1>` and the metainfo block**. It is a *renderer* — a function that receives the page entry and returns renderable output — so it is naturally keyed on the current page's frontmatter rather than re-deriving it from props.

```ts
docContentHeaderExtras?: (args: {
  entry: DocPageEntry;
  slug: string;
  locale: string;
  isFallback?: boolean;
  version?: string;
}) => unknown;
```

A common use is a tier badge derived from a custom frontmatter field. Given pages that declare `tier: core` or `tier: opt-in` in their frontmatter:

```tsx title="src/chrome-bindings.tsx"
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";

export const chromeBindings = defineChromeBindings({
  docContentHeaderExtras: ({ entry }) => {
    const tier = entry.data.tier;
    if (tier !== "core" && tier !== "opt-in") return null;
    const label = tier === "core" ? "Core" : "Opt-in";
    const tone = tier === "core" ? "bg-accent text-bg" : "bg-surface text-fg";
    return (
      <span
        class={`inline-block px-hsp-sm py-vsp-2xs text-caption rounded-full ${tone}`}
      >
        {label}
      </span>
    );
  },
});
```

Default: absent → nothing renders, and the header output is byte-identical to the pre-seam header.

[Dynamic content unavailable in text export]

The renderer is called for `entry` doc pages on all four doc routes, **including versioned pages**, where it receives the `version` argument. On versioned pages the metainfo block and tags are hidden — the renderer decides for itself whether and how to render there. See [Versioning](https://zudo-doc.takazudomodular.com/docs/guides/versioning) for the versioned-route model.



## `homeExtras` and `HomePageView` — home hero injection

`homeExtras` renders extra content INLINE at the end of the home hero's links row, `/`-separated from whatever precedes it (the primary link and/or the GitHub link) — for example, a brand or social link alongside the overview/GitHub links:

For paragraphs, lists or a longer introduction, use [home.introMarkdown](https://zudo-doc.takazudomodular.com/docs/guides/configuration#home-page-home). It renders a separate compact prose section below the identity; `homeExtras` and `extras` retain their inline navigation semantics.

```ts
homeExtras?: (args: { locale: string }) => unknown;
```

```tsx title="src/chrome-bindings.tsx"
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";

export const chromeBindings = defineChromeBindings({
  homeExtras: ({ locale }) => (
    <a
      href="https://example.com/blog"
      class="text-fg underline hover:text-accent"
    >
      Read the blog
    </a>
  ),
});
```

### Precedence: `extras` prop wins

The shared home body is built by `HomePageView`, which the package routes and any host page both call. It accepts an `extras` prop — an already-rendered value the host page passes directly. When both are present, the **prop wins**:

```tsx
// resolved inside HomePageView as:
extras ?? hostBindings.homeExtras?.({ locale })
```

The asymmetry is intentional: a host page has its JSX in hand (a value), while the injected/bindings path only has a `locale` string at render time and must derive its own content from it (a renderer).

### The `/` route topology

The default home at `/` is **never injected** by the routes plugin — zfb rejects a `/` pattern (upstream [Takazudo/zudo-front-builder#1227](https://github.com/Takazudo/zudo-front-builder/issues/1227)). The scaffold's `pages/index.tsx` is a one-line re-export of `@takazudo/zudo-doc/routes/index`; it supplies no `extras` prop. The host file always wins at `/` because there is no injected `/` route to use instead.

- The unmodified **`/` home** uses the package route re-export and does not receive `homeExtras` through an `extras` prop.
- **Injected `/[locale]` homes** read `hostBindings.homeExtras` through `chromeBindingsModule`.

To customize the root home, replace the re-export with your own page that calls `HomePageView` and passes `extras`. For an i18n site, pass the same content there that `homeExtras` returns for locale homes.

## The Design Token Panel channel (`designTokenPanelConfigModule`)

The Design Token Panel has its own host-module channel that works **exactly like `chromeBindingsModule`** — same mechanics, same loud missing-file behavior — but carries the panel's config builder instead of chrome bindings.

The panel needs **no host config file** to work. With `designTokenPanel: true`, the injected `DesignTokenPanelBootstrap` island uses a **package-default builder** (`@takazudo/zudo-doc/design-token-panel-config`) derived from the shipped token manifest and the bundled Default Light / Default Dark schemes. That is the zero-config path.

To fully customize the panel, set `designTokenPanelConfigModule` to a project-root-relative path pointing at a host module that exports a named `buildDesignTokenPanelConfig(mode)`:

```ts title="src/design-token-panel-config.ts"
import type { PanelConfig } from "@takazudo/zdtp";

type PanelMode = "light" | "dark";

export function buildDesignTokenPanelConfig(mode: PanelMode): PanelConfig {
  // return a mode-scoped panel config (spacing/font/size/color tiers)
  // ...
}
```

The routes plugin registers a third virtual module (`virtual:zudo-doc-design-token-panel-config`) that re-exports your builder. As with `chromeBindingsModule`, only the **path** travels through config; the builder is imported via the module graph.

[Dynamic content unavailable in text export]

`designTokenPanelConfigModule` is a first-class `ZudoDocConfig` field, just like `chromeBindingsModule` — pass it directly as an inline `zudoDoc({ … })` key. Enabling the panel (`designTokenPanel: true`) works inline as usual.



These differences from `chromeBindingsModule` matter:

- **The default is the package builder, not an empty object.** When the setting is absent the loader re-exports the package default (a fully working panel) — there is no meaningful "empty" builder to fall back to.
- **Scanner reachability is part of this contract.** Each server boundary statically imports its client target. The package default and routes-only configured boundary own separate fixed targets; the configured client imports the builder through the config virtual module. A host replacement follows the same boundary composition rule below, rather than passing a raw client target through a binding map.
- **Self-contained `pages/` stubs mount no panel while this setting is set.** The setting only reaches pages rendered through the package-injected routes, and the panel is configured once per browser session — so a stub page mounting the package-default bootstrap used to decide the whole session's config whenever it was the hard-loaded entry page. With the setting present (and no explicit `chromeBindings.DesignTokenPanelBootstrap`), stub-rendered pages therefore mount no panel island (and no header panel button) at all; the injected routes still get your configured panel from any entry page. To get a panel on stub-rendered pages too, expose your client bootstrap through the server boundary described below — that binding wins everywhere.

The same guard rules apply: an explicitly empty string, a missing file, or a directory path fails loudly at plugin setup, naming the resolved path. `storagePrefix: "zudo-doc-tweak"` is preserved by the package default so existing user saves carry over.

## DocHistory boundary

The `DocHistory` slot also accepts a server boundary. Import `DocHistoryBoundary` from `@takazudo/zudo-doc/doc-history-area` when using the package client, and bind it as `DocHistory`. It consumes the optional `ssrFallback` child on the server and mounts the fixed client target with `when="idle"` inside a Fragment. A custom boundary must likewise remove `ssrFallback` before passing the remaining serializable props to its client; never transport the fallback JSX as client data. The existing doc-history settings and exclusion gates still control whether the area renders.

## Replacing the panel boundary

In v6, `chromeBindings.DesignTokenPanelBootstrap` accepts a zero-prop **server boundary**, not a raw `"use client"` component. Import the client bootstrap statically and return its fixed-target `Island` inside a Fragment. Keep this server file separate from the client module. The client bootstrap should activate `runDesignTokenPanelBootstrapOnce` using `getScope().onActivate`, as shown in [Mounting your own panel](https://zudo-doc.takazudomodular.com/docs/reference/design-token-panel#implementation-mounting-your-own-panel).

```tsx title="src/panel-boundary.tsx"
/** @jsxRuntime automatic */
import { Island } from "@takazudo/zfb";
import { HostPanelBootstrap } from "./host-panel-bootstrap";

export function HostPanelBoundary() {
  return <>{Island({ when: "load", children: <HostPanelBootstrap /> })}</>;
}
```

```tsx title="src/chrome-bindings.tsx"
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
import { HostPanelBoundary } from "./panel-boundary";

export const chromeBindings = defineChromeBindings({
  DesignTokenPanelBootstrap: HostPanelBoundary,
});
```

Keep `designTokenPanel: true` for this replacement slot. The package still owns its settings gate and pre-hydration toggle shim: disabling the feature emits neither this boundary nor the shim. An explicit host boundary wins even when `designTokenPanelConfigModule` suppresses the package default, and injected routes preserve it over their configured default. To mount a separate host panel with the package feature disabled, use the independent `BodyEndIslands` path described in the linked guide instead.

Direct `createBodyEndIslands` consumers follow the same composition rule for optional `DesignTokenPanelBootstrap` and `ThemePackSwitcher` dependencies: each supplied component owns a fixed-target Fragment-wrapped boundary; the factory only renders it behind the feature gate. Omitting either dependency remains a no-op for that island. Client functions never travel as island props.

## See also

- [Customizing zudo-doc](https://zudo-doc.takazudomodular.com/docs/reference/customizing) — the minimal→extend ladder, of which `chromeBindingsModule` is rung 6
- [Frontmatter Preview](https://zudo-doc.takazudomodular.com/docs/reference/frontmatter-preview) — the `frontmatterRenderers` slot this channel also un-strands
- [Configuration](https://zudo-doc.takazudomodular.com/docs/guides/configuration) — the full `zudoDoc()` field reference
- [Design Token Panel](https://zudo-doc.takazudomodular.com/docs/reference/design-token-panel) — the panel UI this channel configures