Docs recipes
Task recipes for adding pages, ordering the sidebar, wiring the chrome, adding a language, renaming slugs, and theming the tokens.

Recipes for the jobs you do once the docs site exists: add a page, slot it into the sidebar, wire the chrome, add a language, rename a slug without breaking links, and theme it. Each one stands alone, so read the heading you need and stop.
Prerequisites: the package installed, the stylesheet imported once, and a configured Docs instance. See Installation for that, Build a docs site for the end-to-end walkthrough, and API reference for every option, default, and token.
Add a page
Drop a folder into your contentDir. The folder name is the public URL and the file name is the language:
docs/
installation/
en.mdx # the default-locale page -> /docs/installation
fr.mdx # a translation -> /fr/docs/installation
Only immediate subdirectories are walked. There is no recursion and no nested slugs, and the slug is never slugified or escaped: it is the directory name verbatim.
---
title: "Installation"
description: "Install the package and wire your first instance."
tab: "Documentation"
group: "Get started"
order: 2
icon: "rocket"
updated: "2026-08-11"
---
## Install the package
The body starts at `##` - `title` already renders the H1.
title falls back to the slug when it is missing; description falls back to "", which ships a blank meta description, so always write one. readingTime is computed from the body. There is no author, categories, or tags field.
Never create both post.mdx and en.mdx in one folder. Two files resolving to one page throws DuplicateDocError from entries(), which fails the build rather than just that page. Pick one file name per language.
Place the page in the sidebar
Five fields do the slotting. tab is the top-level section, group is the sidebar heading, order sorts within the group, icon names the glyph, and the optional label overrides the sidebar text when the title is too long to scan:
tab: "Documentation"
group: "Guides"
order: 2
icon: "list"
label: "Recipes"
Reuse the exact tab and group strings your other pages use, character for character. They are matched as literals, so "Get started" and "Get Started" are two different groups.
Within a group, pages sort by order ascending; a page with no order sorts after every page that has one. Ties break alphabetically by title - so two pages at order: 2 do not error and do not keep the order you wrote them in, they sort by the alphabet, and the unordered bucket is alphabetical too. Within a group, keep order dense 1..N with no repeats.
The YAML types are load-bearing
The reader keeps a field only when its YAML type is right, and silently drops it otherwise. Nothing errors:
ordermust be a bare number.order: 3is kept.order: "3"is dropped and the page falls to the bottom of its group.hiddenmust be a bare boolean.hidden: trueis kept.hidden: "true"andhidden: 1are not hidden - the check is a strict=== true, so a quoted draft leaks straight into the nav.keywordsmust be a YAML list of- itemlines. A comma-separated string is dropped.iconmust be a bare string.icon: 2024is dropped; unliketitle: 2024, which survives as"2024".
After writing a header, re-read it: order a number, hidden a boolean, keywords a list.
The icon set is fixed
icon maps against a built-in set. Valid names, and only these:
book, rocket, workflow, phone, voice, calendar, globe, clock, plug, link, mail, grid, list, check, gear, code, sparkles, shield, document.
Any other name silently renders the neutral document glyph rather than erroring, so a typo looks like a deliberate choice forever. To use your own glyphs instead, pass renderIcon to DocsSidebar, DocsIndex, and DocsSearchProvider - the command palette renders its own result icons, so miss that third one and your custom set silently reverts to the built-ins inside ⌘K.
The docs index card for a group takes the icon of that group's first page, so give whichever page sorts first an icon that represents the whole group.
Order the tabs and groups themselves
order sorts pages inside a group. To sort the tabs and the groups, list them on the Docs instance. Both arrays are purely presentational, and each entry is a bare id, or an object carrying a display label and an optional description - the one-line blurb the docs index prints under that topic's heading:
import { Docs } from "@daanvandenbergh/scribekit";
export const docs = new Docs({
contentDir: "./docs",
siteUrl: "https://example.com",
brandName: "My Site",
tabs: ["Documentation", { id: "Skills", label: "Claude skills" }],
groups: [
{ id: "Get started", description: "Install it and render your first page." },
"Guides",
"Reference",
],
});
Both label and description also accept a per-locale map - { en: "Claude skills", fr: "Compétences Claude" } - resolved at the current language, then at defaultLocale, then falling through to the id. On a translated corpus use the map form: a bare string prints the same language on every localized page.
Tabs and groups then sort on three keys, in this order:
- Config index. Everything you listed comes first, in the order you listed it. Anything you did not list comes after.
- Minimum descendant
order. Among the unlisted, the section holding the lowest-orderpage wins. - Discovery order. The final tie-break is the order the directory was read in.
So listing a tab or a group is how you pin it; leaving it out hands the decision to your page order values and then to the filesystem.
Hide a page
Set the bare boolean:
hidden: true
Be precise about what this does, because it is not access control. The page stays routable: it is still in getDocRefs(), so generateStaticParams still prerenders it and a direct link still works, and it is still in getAllDocs(). It gets robots: { index: false } in its metadata.
What it loses:
- the nav tree, and therefore the sidebar and the command palette
- prev/next (
getAdjacentreturns{}) - the breadcrumb (
getBreadcrumbreturnsundefined) - the sitemap
- the docs index - both its topic cards and the
ItemListin its JSON-LD - its siblings' hreflang alternates, and the JSON-LD translation cross-links
It is "unlisted", not "private". Anyone with the URL reads the page.
Add the navbar, tabs, sidebar, and search palette
The chrome is interactive - the ⌘K palette, the sliding tab indicator, the active-page highlight - and all of it needs the current pathname, which is a client hook. So put the chrome in a thin client component and build the nav tree in the server layout above it.
// app/docs/_docs-chrome.tsx
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
DocsNavbar,
DocsNavbarButton,
DocsSearchProvider,
DocsSidebar,
DocsTabs,
} from "@daanvandenbergh/scribekit/react";
import type { NavTree } from "@daanvandenbergh/scribekit";
export function DocsChrome({ nav, lang, children }: { nav: NavTree; lang: string; children: ReactNode }) {
const activePath = usePathname();
return (
<DocsSearchProvider nav={nav} lang={lang} linkComponent={Link}>
<div className="scribekit-docs">
<DocsNavbar
brandName="My Site"
docsText="Docs"
homeHref="/"
linkComponent={Link}
lang={lang}
actions={[
<DocsNavbarButton key="blog" href="/blog" linkComponent={Link}>
Blog
</DocsNavbarButton>,
]}
/>
<DocsTabs nav={nav} activePath={activePath} lang={lang} linkComponent={Link} />
<div className="scribekit-docs-body">
<DocsSidebar
nav={nav}
activePath={activePath}
lang={lang}
linkComponent={Link}
brand={<strong>My Site</strong>}
footer={[
<DocsNavbarButton key="blog" href="/blog" linkComponent={Link}>
Blog
</DocsNavbarButton>,
]}
/>
<main className="scribekit-docs-main">{children}</main>
</div>
</div>
</DocsSearchProvider>
);
}
Put it in the layout, not the page, so the open tab, the scroll position, and the palette survive client-side navigation:
// app/docs/layout.tsx
import type { ReactNode } from "react";
import { docs } from "./_docs";
import { DocsChrome } from "./_docs-chrome";
export default function DocsLayout({ children }: { children: ReactNode }) {
return (
<DocsChrome nav={docs.getNavTree()} lang={docs.defaultLocale}>
{children}
</DocsChrome>
);
}
Five rules that decide whether this works:
DocsNavbarmust sit insideDocsSearchProvider. The provider owns the single palette that both the navbar's search box and the ⌘K shortcut raise, and the drawer state the mobile hamburger toggles. Outside it,open()is a no-op and the navbar drops its hamburger entirely: nothing throws, but below the layout breakpoint the navigation has no opener at all. The navbar renders the search box itself viashowSearch, which defaults totrue. To trigger the palette from somewhere else, renderDocsSearchButtoninside the provider, or calluseDocsSearch().open().- Navbar
actionsauto-hide, so pass the same nodes to the sidebar'sfooter. The navbar measures itself and drops the wholeactionsgroup when the bar is too narrow, marking itselfdata-cramped; the stylesheet then reveals the sidebar drawer'sfooterin its place. Give both slots the same nodes, as above, or your Blog button simply vanishes on a phone with nothing to replace it.brandis the drawer's own lockup. DocsTabsreturnsnullwhen!nav.multiTab. A corpus whose pages all share onetab, or carry none, renders no tab bar at all. LeavingDocsTabsin the tree costs nothing and starts working the day you add a second tab.- The table of contents is already in
DocsPage(showToc, defaulttrue).DocsTocis only for placing the minimap yourself, and it returnsnullwhentoc.length === 0, so a page with no##or###headings renders no empty shell. - Do not override
h2orh3inDocsPage'scomponentsmap.DocsPageinjects anchor ids into those two headings, and that is what the minimap jumps to. Your map is spread last, so yourh2wins and the ids vanish along with the jump links. If you must override them, inject your own ids.
Add another language
List the locale on the instance first. This is the step people skip:
export const docs = new Docs({
contentDir: "./docs",
siteUrl: "https://example.com",
brandName: "My Site",
locales: [
{ code: "en", label: "English" },
{ code: "fr", label: "Français", dateLocale: "fr-FR" },
],
defaultLocale: "en",
});
Then drop fr.mdx beside en.mdx in the same folder. The file name is the language; there is no front-matter language field.
A fr.mdx whose fr is not in config.locales is never discovered. No error, no page, no warning: discovery only looks for the locales you configured, so the file is invisible to getDocRefs, the nav, and the sitemap. Configure the locale, then write the file.
The URL shape
The locale prefix comes first, then the base path: /<lang><basePath>/<slug>/.
| Page | URL |
|---|---|
installation/en.mdx, defaultLocale: "en" | /docs/installation/ |
installation/fr.mdx | /fr/docs/installation/ |
installation/en.mdx with prefixDefaultLocale: true | /en/docs/installation/ |
It is /fr/docs/installation/, never /docs/fr/installation/. Move the route tree to app/[lang]/docs/[slug] and map both fields in generateStaticParams.
Every URL ends in a slash because trailingSlash defaults to true, the form Next serves under trailingSlash: true - which is what the GitHub Pages setup turns on. The instance and your next.config must agree, and Next's own default is false: set trailingSlash: true in next.config, or trailingSlash: false on the Docs. Every canonical, hreflang, sitemap entry, and nav link comes from one path builder, so a mismatch silently aims the whole site at URLs your host does not serve.
The language picker
import { DocsLanguagePicker } from "@daanvandenbergh/scribekit/react";
<DocsLanguagePicker
locales={docs.locales}
currentLang={lang}
defaultLocale={docs.defaultLocale}
prefixDefaultLocale={docs.prefixDefaultLocale}
trailingSlash={docs.trailingSlash}
activePath={activePath}
basePath="/docs"
linkComponent={Link}
/>
Pass it to DocsNavbar's languagePicker prop.
basePath defaults to /blog, so docs consumers must pass basePath="/docs" explicitly. The picker shares the blog's path helper and its default, so omitting it points every language link at your blog. Pass your own basePath even when it is the Docs default.
The picker returns null when locales.length <= 1, so it costs nothing to leave in a single-language site.
Rename a slug without breaking links
Rename the folder, then map the old slug to the new one:
export const docs = new Docs({
contentDir: "./docs",
siteUrl: "https://example.com",
brandName: "My Site",
redirects: {
"old-slug": "new-slug",
},
});
The route has to do two things: prerender the old slug, and redirect it when it renders.
// app/docs/[slug]/page.tsx
import { DocsPage } from "@daanvandenbergh/scribekit/react";
import { DocNotFoundError } from "@daanvandenbergh/scribekit";
import { notFound, permanentRedirect } from "next/navigation";
import { docs } from "../_docs";
export const dynamicParams = false;
export function generateStaticParams() {
return [...docs.getDocRefs(), ...docs.getRedirectRefs()].map((ref) => ({ slug: ref.slug }));
}
export default async function DocSlugPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
try {
docs.getDoc(slug);
} catch (err) {
if (err instanceof DocNotFoundError) {
const renamedTo = docs.getRedirect(slug);
if (renamedTo) permanentRedirect(renamedTo);
notFound();
}
throw err;
}
return <DocsPage docs={docs} slug={slug} />;
}
getRedirectRefs() is not optional. With dynamicParams = false, an unrendered slug 404s at the router, before the component that would redirect it ever runs. Without those refs in generateStaticParams, the redirect config is dead code.
getRedirect hands back a destination that is already locale-prefixed and base-rooted, ready to pass straight to permanentRedirect() for a 308.
Three resolution rules govern the map:
- A real page always wins. If the source slug still exists on disk, the entry is inert. A stale redirect can never shadow a live page.
- Chains resolve in one hop.
{ a: "b", b: "c" }sendsastraight toc, not throughb. - Cycles yield nothing.
{ a: "b", b: "a" }returnsundefined, so the URL 404s instead of looping.
Theme it
Declare the --scribekit-* tokens yourself:
:root {
--scribekit-primary: #6d5df6;
--scribekit-ink: #0b1b36;
--scribekit-font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
There is no :root block in the stylesheet to override. Every token you would theme exists only as an inline var(--name, fallback) at each call site, and that fallback is what you see when you declare nothing. Declaring the property anywhere it inherits from makes every call site pick it up. (The one exception is the internal --scribekit-docs-chrome-top, which the stylesheet assigns itself - see below.) API reference lists the tokens.
No dark mode ships. The stylesheet has no prefers-color-scheme block and no .dark class. Dark theming is entirely yours, and the tokens are the whole mechanism:
:root[data-theme="dark"] {
--scribekit-surface: #0b1b36;
--scribekit-ink: #f8fafc;
--scribekit-border: #1e2f52;
}
Budget for covering the surfaces, the ink, and the borders at minimum.
Offsetting an app-level header
The sticky columns (the sidebar and the minimap) must start where the sticky chrome above them ends, or they slide underneath it as the article scrolls. If your docs shell sits under your own app header, set the offset:
:root {
--scribekit-docs-content-top: 64px;
}
--scribekit-docs-content-top is the consumer's override and it always wins.
Do not set --scribekit-docs-chrome-top. It is internal and computed: sibling-selector rules derive it from which chrome actually rendered, because DocsTabs renders nothing for a single-tab corpus and the offset cannot be known ahead of time. Setting it by hand fights those rules.