# @daanvandenbergh/scribekit

![scribekit - a drop-in MDX blog and docs site for Next.js](.agentstore/scribekit-hero/readme/hero.png)

Drop a fast, SEO-ready **blog** and **docs site** into any **Next.js** app - and write both with **Claude Code**.

Built specifically for the **App Router** (React Server Components). One package, two surfaces:

- **Blog** - the `Blog` class reads `.mdx` posts from a folder and builds their SEO metadata + JSON-LD. `<BlogOverview>` renders the `/blog` grid (fuzzy search, category filters, infinite scroll); `<BlogPage>` renders a post with a heading minimap, reading time, and similar posts.
- **Docs** - the `Docs` class turns a folder of `.mdx` pages into a tabbed, grouped nav assembled from front-matter (no config file). `<DocsNavbar>` / `<DocsTabs>` / `<DocsSidebar>` / `<DocsPage>` / `<DocsToc>` / `<DocsIndex>` render a full-width docs app with a ⌘K command palette, breadcrumbs, prev/next, and a scroll-spy "On this page" minimap.
- **Claude Code skills** - research, write, and rewrite posts and docs pages in your project's house style, and generate on-brand hero images.

You build the page chrome (navbar, footer, hero); scribekit renders the blog and docs body. Both surfaces share the same [theming tokens](#theming), [locales](#multiple-locales), and [MDX pipeline](#mdx-plugins).

**Contents:** [Install](#install) · [Claude Code skills](#the-claude-code-skills) · [Demo](#demo) · [Blog](#blog) · [Docs](#docs) · [Multiple locales](#multiple-locales) · [Theming](#theming) · [MDX plugins](#mdx-plugins) · [API reference](#api-reference) · [Notes](#notes)

---

## Install

```bash
npm install @daanvandenbergh/scribekit
```

Peer dependencies (you almost certainly already have the first three): `next`, `react`, `react-dom`, and `next-mdx-remote` (used to render MDX).

```bash
npm install next-mdx-remote
```

Import the stylesheet once (e.g. in your root `app/layout.tsx`):

```tsx
import "@daanvandenbergh/scribekit/styles.css";
```

---

## The Claude Code skills

The package ships four Claude Code skills. Symlink them into your project's `.claude/skills` so Claude Code picks them up:

```bash
mkdir -p .claude/skills
ln -s ../../node_modules/@daanvandenbergh/scribekit/skills/scribekit-blog .claude/skills/scribekit-blog
ln -s ../../node_modules/@daanvandenbergh/scribekit/skills/scribekit-docs .claude/skills/scribekit-docs
ln -s ../../node_modules/@daanvandenbergh/scribekit/skills/scribekit-hero .claude/skills/scribekit-hero
ln -s ../../node_modules/@daanvandenbergh/scribekit/skills/scribekit-docs-github-pages .claude/skills/scribekit-docs-github-pages
```

- **`scribekit-blog`** - writes and rewrites blog posts following its research protocol, house style, and SEO checklist. Ask Claude Code to write or rewrite a post (`rewrite --scan` reports only). Each post's hero is generated by `scribekit-hero`, which the `write` flow calls automatically.
- **`scribekit-docs`** - writes, rewrites, and **reorganizes** documentation pages, sourced from your own code so every option, default, and code sample is true. Ask it to write a page (it auto-proposes the highest-value gap), rewrite one against the current code, or `reorganize` the whole corpus - it redesigns the tab/group/order structure, fixes the front-matter that silently breaks the nav, and reports badly-named slugs without renaming any (so no URL ever changes). `--scan` reports only. It reuses `scribekit-blog`'s house style and research protocol, so **install `scribekit-blog` alongside it**.
- **`scribekit-hero`** - all hero-image generation, rendered on-brand from HTML/CSS (never an AI image generator): blog/docs page heroes, regenerate-all after a design change, gradient tuning, and a README banner in the same family. Self-contained - you can install it with or without the others.
- **`scribekit-docs-github-pages`** - publishes your docs site (the whole Next.js app) to **GitHub Pages** as a static export. It learns the project, adds `output: 'export'` to your Next config, writes the deploy workflow, points `siteUrl` at the Pages origin, and handles what static export changes in the routing layer (middleware clean-URLs and renamed-slug redirects). See [Deploy to GitHub Pages](#deploy-to-github-pages).

### The CLAUDE.md rule

The package also ships one CLAUDE.md rule snippet. Claude Code inlines any `@path` reference in `CLAUDE.md` at load time, so point it straight at the file in `node_modules` and it auto-updates with the package:

```md
@node_modules/@daanvandenbergh/scribekit/rules/docs_parity.md
```

- **`rules/docs_parity.md`** - docs and `README.md` are updated in the same change as the code, never deferred. Keeps your docs site true to the code between `scribekit-docs` runs.

---

## Demo

A complete, runnable reference app lives in [`demo/`](./demo) - a minimal Next.js App Router site with a bilingual `Blog` at `/blog` and a tabbed `Docs` site at `/docs` (one `Blog` + one `Docs` instance, their route trees, and folders of MDX). Copy its layout into your own site, or run it from the repo root: `npm install && npm run demo` (builds the package, then serves the demo at http://localhost:3000).

---

## Blog

### Quick start (3 files)

**1. Configure one `Blog` instance.** Pass this one instance to the components - they derive the posts, the date locale, and the SEO JSON-LD from it (`blog.site` is public too).

```ts
// app/blog/_blog.ts
import { Blog } from "@daanvandenbergh/scribekit";

export const blog = new Blog({
    contentDir: "./blog", // where your <slug>/en.mdx post folders live (repo root ./blog)
    siteUrl: "https://example.com",
    brandName: "Example",
});
```

**2. The `/blog` overview page.**

```tsx
// app/blog/page.tsx
import { BlogOverview } from "@daanvandenbergh/scribekit/react";
import { blog } from "./_blog";

export function generateMetadata() {
    return blog.overviewMetadata();
}

export default function BlogIndexPage() {
    return <BlogOverview blog={blog} />;
}
```

**3. The `/blog/[slug]` post page.** `BlogPage` loads the post from the slug, so the page stays a one-liner. With `dynamicParams = false`, unknown slugs 404 at the router before it renders.

```tsx
// app/blog/[slug]/page.tsx
import { BlogPage } from "@daanvandenbergh/scribekit/react";
import { blog } from "../_blog";

export const dynamicParams = false; // unknown slugs 404 instead of rendering on demand

export function generateStaticParams() {
    return blog.getPostRefs().map(({ slug }) => ({ slug })); // URL slugs (see `slug:` below)
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    try {
        return blog.postMetadata(blog.getPost(slug));
    } catch {
        return { title: "Post not found" };
    }
}

export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    return <BlogPage blog={blog} slug={slug} />;
}
```

Wrap the components in your own navbar/footer; pass a heading to `<BlogOverview header={...} />` if you want one above the grid.

> **Tip:** set `metadataBase` (or a `title`) in your root `layout.tsx` metadata so relative URLs resolve app-wide. The helpers also set `metadataBase` from `site.siteUrl` on each page.

### Writing posts

Each post is a folder in your `contentDir` (default: a top-level `blog/`) holding a body named by its locale - `en.mdx` (the language-neutral `post.mdx` also works). **The slug is the folder name** - `blog/how-i-work/en.mdx` becomes `/blog/how-i-work` - unless the file declares its own `slug:` (see [per-language slugs](#per-language-slugs)). The front-matter contract:

```mdx
---
title: "Short, specific, front-loads the primary keyword"
description: "Benefit-led summary. Used for the card, meta description, and OG/Twitter."
date: "2026-07-07"            # ISO YYYY-MM-DD - ALWAYS quote it
keywords:                     # optional
    - primary keyword
    - secondary keyword
categories:                   # optional; usually one, from a small reused taxonomy; always a list
    - Guides
author: "Jane Doe"            # optional; defaults to site.defaultAuthor, then brandName
author-image: "/assets/blog/authors/jane-doe.jpg"   # optional; avatar for the author, see below
author-url: "/about"          # optional; makes the author name a link, see below
image: "/assets/blog/how-i-work/hero.en.jpg"   # optional; see below
updated: "2026-07-08"         # optional
slug: "how-i-work"            # optional; this file's own URL slug, see "Per-language slugs"
---

## Start the body at H2

The `title` above is your H1 - don't repeat it here.
```

- **Quote the date.** An unquoted YAML date parses as a `Date`, not a string, and is dropped.
- **Categories** power the overview's filter buttons - reuse a small taxonomy; the bar hides when every post shares one category. Reading time is computed from the body (no front-matter needed).
- **Hero images** live in `public/` and are referenced by `image` as a served path, one per language (`hero.en.jpg`, `hero.fr.jpg`). The `scribekit-hero` skill generates them and fills this field.
- **Authors**: setting `author` shows the name in the meta row and a "Written by" bio closing the article; `author-image` is the avatar (a served path); `author-url` makes the name in both spots a link (through your `linkComponent`), e.g. to `/about`. Leave them unset and both spots render as plain text without a picture.

### Post sidebar

`BlogPage` renders a right-side sidebar: a **heading minimap** (a TOC from the body's `##`/`###` headings with jump links and a scroll-tracking active highlight), the estimated **reading time**, and **similar posts**. It collapses into a tap-to-expand bar on narrow viewports.

- Similar posts are ranked by weighted-term cosine similarity over each post's `keywords`, `title`, and `description`; add `keywords` for better matches.
- The minimap needs anchor `id`s on `##`/`###` headings (injected for you). If you override `h2`/`h3` via `components`, add your own `id`s with the exported `slugify`.
- Turn it off with `showSidebar={false}`; customise via `tocTitle`, `similarTitle`, `similarCount` (default 3), and `readingLabel`.

A post whose `updated` front-matter is later than its `date` also carries an "Updated <date>" item in the meta row, in the post's own language; override the wording with `updatedLabel`.

```tsx
<BlogPage blog={blog} slug={slug} similarCount={4} tocTitle="Contents" />
```

`BlogSidebar` is a **client component** (scroll-spy + mobile collapse need the browser); `BlogPage` stays a server component and passes it already-computed data. It's exported from `/react` if you want to place it yourself.

### Overview: search, filters & pagination

Above the grid, `BlogOverview` adds:

- **Fuzzy search** - filters as you type, matching (typo-tolerantly, via [Fuse.js](https://www.fusejs.io/)) on `title`, `description`, `keywords`, and `categories`.
- **Category filters** - one button per distinct category, plus "All"; **hidden when every post shares a single category**.
- **Infinite scroll** - reveals posts in `pageSize` batches (default 9) with a "Load more" fallback.

All optional - tune with props:

```tsx
<BlogOverview
    blog={blog}
    linkComponent={Link}
    pageSize={12}
    searchPlaceholder="Search the blog…"
    loadMoreLabel="Show more"
    allCategoriesLabel="All posts"
/>
```

The interactive parts live in `BlogOverviewGrid`, a **client component** rendered by (and exported alongside) `BlogOverview`; the `Blog` instance and SEO JSON-LD stay on the server.

---

## Docs

The `Docs` class turns a folder of `.mdx` pages into a tabbed, grouped navigation - assembled from each page's front-matter, **not** a separate config file. The components render the app: a top bar (`DocsNavbar`), a tab bar (`DocsTabs`), a left nav (`DocsSidebar`), a page with breadcrumb / meta / prev-next (`DocsPage`), a right-hand scroll-spy minimap (`DocsToc`, rendered by `DocsPage`), and a landing page (`DocsIndex`, composing `DocsHero` + `DocsTopicGrid` + `DocsRecentlyUpdated`, each exported separately). One `DocsSearchProvider` owns the ⌘K palette the navbar button and keyboard shortcut share. Like the blog, `DocsNavbar` is optional and you own the rest of the chrome.

### 1. Configure one `Docs` instance

```ts
// app/docs/_docs.ts
import { Docs } from "@daanvandenbergh/scribekit";

export const docs = new Docs({
    contentDir: "./docs", // where your <slug>/en.mdx page folders live
    siteUrl: "https://example.com",
    brandName: "Example",
    // optional: pin the display order of the top tabs and the sidebar groups
    tabs: ["Documentation", "Guides", "API reference"],
    groups: ["Get started", "Configuration", "Reference"],
});
```

### 2. The docs shell + `/docs` landing page

The chrome (navbar + tab bar + left nav) is persistent, so render it in a **layout** around the routed page. The pieces are client components (⌘K search, sliding indicator, active-page highlight) that need the current path, so a thin `"use client"` wrapper reads `usePathname()` and lays them out inside a `DocsSearchProvider`. The shell classes (`.scribekit-docs`, `.scribekit-docs-body`, `.scribekit-docs-main`) come from the stylesheet:

```tsx
// 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, DocsTabs, DocsSidebar } from "@daanvandenbergh/scribekit/react";
import type { NavTree } from "@daanvandenbergh/scribekit";

export function DocsChrome({ nav, children }: { nav: NavTree; children: ReactNode }) {
    const activePath = usePathname();
    return (
        <DocsSearchProvider nav={nav} linkComponent={Link}>
            <div className="scribekit-docs">
                <DocsNavbar
                    logo={<MyLogo />}
                    logoSize={22}
                    brandName="Example"
                    docsText="Docs"
                    linkComponent={Link}
                    // The right-hand actions - the part you'll most often edit. A list of buttons,
                    // styled to match the design (link / primary / secondary variants).
                    actions={[
                        <DocsNavbarButton key="support" href="/support" linkComponent={Link}>Support</DocsNavbarButton>,
                        <DocsNavbarButton key="dash" href="/dashboard" linkComponent={Link} variant="primary">Dashboard</DocsNavbarButton>,
                    ]}
                />
                <DocsTabs nav={nav} activePath={activePath} linkComponent={Link} />
                <div className="scribekit-docs-body">
                    <DocsSidebar nav={nav} activePath={activePath} linkComponent={Link} />
                    <main className="scribekit-docs-main">{children}</main>
                </div>
            </div>
        </DocsSearchProvider>
    );
}
```

```tsx
// 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()}>{children}</DocsChrome>;
}
```

```tsx
// app/docs/page.tsx
import { DocsIndex } from "@daanvandenbergh/scribekit/react";
import { docs } from "./_docs";

export function generateMetadata() {
    return docs.indexMetadata();
}

export default function DocsIndexPage() {
    return <DocsIndex docs={docs} />;
}
```

### 3. The `/docs/[slug]` page

```tsx
// app/docs/[slug]/page.tsx
import { DocsPage } from "@daanvandenbergh/scribekit/react";
import { docs } from "../_docs";

export const dynamicParams = false; // unknown slugs 404 instead of rendering on demand

export function generateStaticParams() {
    return docs.getDocSlugs().map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    try {
        return docs.docMetadata(docs.getDoc(slug));
    } catch {
        return { title: "Page not found" };
    }
}

export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    return <DocsPage docs={docs} slug={slug} />;
}
```

`DocsPage` renders the breadcrumb, title, lead, reading time, MDX body, a "Was this page helpful?" widget, prev/next cards, and the `DocsToc` minimap - all from `docs`. Every docs component takes a `lang` for [multi-locale](#multiple-locales) docs. Docs render **full-width** (they're an app, not a centered article), so mount them outside any `max-width` container - or set `--scribekit-docs-max-width` to cap and centre the shell.

### Writing docs pages

Each page is a folder in `contentDir` holding a body named by its locale (`en.mdx`; `post.mdx` also works). **The slug is the folder name** - `docs/greeting/en.mdx` becomes `/docs/greeting`. The front-matter contract:

```mdx
---
title: "Greeting & voice"        # the H1 and default sidebar label
description: "Shape the first thing every caller hears."   # the lead + meta description
tab: "Documentation"             # optional; top-level section (the tab switcher)
group: "Configuration"           # optional; sidebar group heading
order: 2                         # optional; sort within the group (ascending)
icon: "voice"                    # optional; a glyph name from the built-in set
label: "Greeting"                # optional; sidebar label override (defaults to title)
updated: "2026-07-09"            # optional; shown in the meta row - quote it
image: "/assets/docs/greeting/hero.jpg"   # optional; OG image
keywords: ["greeting", "voice"]  # optional
hidden: false                    # optional; true = routable via direct link, but out of the nav, sitemap & noindex
---

## Start the body at H2

The `title` above is your H1 - don't repeat it here.
```

### Navigation: tabs, groups & order

The sidebar is assembled from the pages themselves - **there is no separate nav config file**:

- **`tab`** places a page under a top-level section. More than one distinct tab shows the top tab bar; a single tab hides it. Order/label with the `tabs` config.
- **`group`** lists pages under an uppercase heading. Order/label with the `groups` config.
- **`order`** sorts pages within their group (ascending); unordered pages sort after, then by title.
- **`hidden: true`** keeps a page routable via direct link but out of the sidebar, prev/next, index, sitemap, and served `robots: noindex` (so a draft isn't crawled).

`docs.getNavTree(lang)` returns the resolved tree (`tabs → groups → ordered pages`) and drives `getBreadcrumb`, `getAdjacent` (prev/next), and `DocsIndex`'s topic cards.

### Renaming a page: `redirects`

The slug is the folder name, so renaming the folder changes the page's public URL. List the old slug in `redirects` and scribekit keeps it alive as a permanent **308**:

```ts
export const docs = new Docs({
    contentDir: "./docs",
    redirects: { "getting-started": "quickstart" },   // old slug -> new slug
});
```

Then serve it from the `[slug]` page. **Both lines are needed**: with `dynamicParams = false` an unrendered slug 404s *at the router*, so an old slug must be prerendered for the component to run at all.

```tsx
import { notFound, permanentRedirect } from "next/navigation";

export function generateStaticParams() {
    return [...docs.getDocSlugs(), ...docs.getRedirectRefs().map((ref) => ref.slug)].map((slug) => ({ slug }));
}

export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    try {
        docs.getDoc(slug);
    } catch {
        const renamedTo = docs.getRedirect(slug); // already locale-prefixed and basePath-rooted
        if (renamedTo) {
            permanentRedirect(renamedTo);
        }
        notFound();
    }
    return <DocsPage docs={docs} slug={slug} />;
}
```

`getRedirect` returns the destination URL for the requested language, so a [multi-locale](#multiple-locales) site needs no extra wiring - pass `lang` and `/fr/docs/getting-started` lands on `/fr/docs/quickstart`. A **real page always wins** over a redirect entry with the same slug (a stale entry is inert), chains resolve to their final destination in one hop, and a cycle 404s rather than looping.

### Icons

`icon` names a glyph from the built-in set (`book`, `rocket`, `workflow`, `gear`, `globe`, `clock`, `phone`, `voice`, `calendar`, `mail`, `link`, `grid`, `list`, `code`, `shield`, `document`, …). Unknown names fall back to a neutral document glyph. Pass your own set with the `renderIcon` prop on `DocsSidebar` / `DocsIndex`: `renderIcon={(name) => <MyIcon name={name} />}`.

### Command palette, minimap & prev/next

- **⌘K / Ctrl-K** (or the navbar's search button) opens the `DocsSearchProvider` command palette - a fuzzy search ([Fuse.js](https://www.fusejs.io/)) over every page's title, label, group, and tab, with ↑/↓ + Enter. It's a proper modal (traps Tab, returns focus on close).
- **`DocsPage`** injects anchor `id`s into `##`/`###` headings (via `slugify`) so the `DocsToc` minimap and its scroll-spy resolve. If you override `h2`/`h3` via `components`, add your own `id`s.
- **Prev/next** cards come from the sidebar reading order (`getAdjacent`); the "Was this page helpful?" widget is self-contained (pass `onFeedback` to `DocsPage` to record the vote).

---

## Deploy to GitHub Pages

A scribekit docs site is already static-export-ready - the `Docs` class reads the filesystem at build time, pages render as server components, search is client-side, and every route ships `generateStaticParams` - so `next build` with `output: 'export'` prerenders the whole site to static HTML with no library code changes.

The easiest path is the **`scribekit-docs-github-pages`** skill: run it in Claude Code and it sets everything up for you - merges `output: 'export'` (and `images: { unoptimized: true }`) into your `next.config`, adds `public/.nojekyll`, writes `.github/workflows/deploy.yml`, points `siteUrl` at your Pages origin, and handles the static-export blockers below. If your `docs/` is just a folder of MDX with **no app rendering it yet**, the skill also **scaffolds the host app** (the flat single-locale layout below) from the corpus first. Then just **push** - the workflow's `configure-pages` step sets `enablement: true`, so it turns Pages on itself on the first run (if an org policy blocks that, enable it once by hand: repo Settings -> Pages -> Source: "GitHub Actions").

Not using Claude Code? The workflow is shipped ready to copy at `node_modules/@daanvandenbergh/scribekit/skills/scribekit-docs-github-pages/assets/deploy.yml` - drop it in `.github/workflows/`, add the `next.config` lines, set `siteUrl`, push. It enables Pages (`enablement: true`) and derives your Pages base path from `actions/configure-pages@v6`, passing it to the build as `NEXT_PUBLIC_BASE_PATH` so `next.config` and the hero `<img>` resolve the correct `/<repo>` prefix automatically.

**Keep `trailingSlash` matched on both sides.** The skill's `next.config` sets `trailingSlash: true`, which is also scribekit's own default: the export then writes `docs/<slug>/index.html`, so Pages serves `/docs/<slug>/` and redirects the bare `/docs/<slug>` to it. Turn it off in `next.config` and the export writes `docs/<slug>.html` instead (only the bare form resolves) - so set `trailingSlash: false` on your `Docs`/`Blog` instance too. A mismatch never throws; it just aims every canonical, hreflang, sitemap entry and nav link at the form your host doesn't serve.

**Static export changes two routing behaviours** (both are conveniences, not the docs themselves):

- **Middleware clean-URLs *break*.** The [multi-locale](#multiple-locales) trick that serves the default language at a bare `/docs/...` (a `proxy.ts`/`middleware.ts` rewrite) can't run on a static host. Set `prefixDefaultLocale: true` so every locale is a real static path (`/en/docs/...`, `/fr/docs/...`) and drop the middleware. A single-locale site has no middleware and exports as-is.
- **Renamed-slug `redirects` *degrade*.** The build still succeeds and renamed slugs still redirect - Next turns the request-time 308 into a **client-side** redirect (the old URL is a near-empty `200` whose JS navigates on). The only loss is SEO / no-JS: to a crawler the old URL is a blank `200` with no true 308 or canonical. Leave it if those URLs aren't SEO-critical, or have the skill emit a static `<meta refresh>` + canonical stub instead.

**Hosting: a project site works, a root site is that bit cleaner.** On a **project site** (`<owner>.github.io/<repo>/`) three raw paths a corpus carries would otherwise 404 under the `/<repo>` subpath - in-body prose links, hero images, and absolute SEO URLs. The skill's scaffold handles the first two automatically: it routes the MDX `a` through `next/link` and renders the hero through a base-path-aware `<img>`, and the workflow feeds the base path in from `configure-pages`. So heroes, links, and assets all resolve on a plain project site - **no custom domain required.** The one residue is scribekit's absolute SEO URLs (canonical/sitemap/OG omit `/<repo>`); if that matters, a **custom domain** (add a `public/CNAME`) or a **user/org `<owner>.github.io` site** serves at the root with an empty base path, where even those are correct.

---

## Multiple locales

Applies to **both blog and docs** (the examples below use `/blog`; docs is identical with `/docs`). Optionally publish the same content in several languages, each at its own SEO-friendly URL with correct `hreflang` / `og:locale` / `inLanguage` (the locale's `dateLocale` when it declares one, e.g. `en-GB`). Fully opt-in: with no `locales` configured, everything here is inert and behaves as a single-language site.

**File layout - one file per language in the folder.** Each language is a `<lang>.<ext>` file in the **same** `<slug>/` folder, sharing the base slug - the default included, named by its own code (`en.mdx`; `post.<ext>` also resolves as a fallback):

```
blog/getting-started/en.mdx     ->  /blog/getting-started        (default, e.g. en)
blog/getting-started/fr.mdx     ->  /fr/blog/getting-started     (fr translation, same slug)
```

A file's language comes from its name (never front-matter, so it can't drift). The default is served at the clean, unprefixed `/blog/<slug>`; every other language is prefixed (`/<lang>/blog/<slug>`). There is **no silent fallback** - only files that exist get a route and a hreflang entry.

<a id="per-language-slugs"></a>**Per-language slugs (blog only, opt-in).** A translation may carry its own keyword-led URL slug by declaring `slug:` in its front-matter; the **folder name stays the translation key** (it pairs the files, and names the hero/asset folder). With no `slug:` anywhere, nothing changes:

```
blog/missed-calls/en.mdx                                 ->  /blog/missed-calls
blog/missed-calls/nl.mdx   (slug: gemiste-oproepen)      ->  /nl/blog/gemiste-oproepen
```

- `meta.slug` is that language's URL slug; `meta.folder` is set only when it differs (read the folder as `meta.folder ?? meta.slug`).
- `getPostRefs()` lists the localized slugs, so `generateStaticParams` needs no change. `getPost(slug, lang)` resolves the localized slug - and the folder name does **not** also resolve in a language that declared its own (one page, one URL).
- Every alternate - hreflang, `sitemapEntries()`, the JSON-LD translation links, RSS, overview cards and similar-post links - uses each sibling's **own** slug. `getTranslations(slug)` accepts the folder or any language's slug; `slugFor(slug, lang)` translates one into another language's slug (what a language switcher needs).
- A slug is lower-case kebab-case (`a-z`, `0-9`, single hyphens) and unique across the blog (no other post's folder name or slug, in any language - so a slug always names one post); a violation throws at load (`DuplicatePostError` for a clash). Never change a published slug - that is a new URL. Docs pages keep folder slugs.

**Configure the locales** on the one `Blog` (or `Docs`) instance:

```ts
// app/[lang]/blog/_blog.ts  (colocated with the routes; the leading _ keeps Next from routing it)
export const blog = new Blog({
    contentDir: "./blog",
    siteUrl: "https://example.com",
    brandName: "Example",
    locales: [
        { code: "en", label: "English" },
        { code: "fr", label: "Français", dateLocale: "fr-FR" }, // label + dateLocale are optional
        // `dateLocale` is the locale's full BCP 47 tag: it formats that language's dates AND is the
        // tag `og:locale` is derived from, so an `en` locale declaring `en-GB` publishes `en_GB`
        // rather than the `en_US` the bare subtag would maximize to.
    ],
    defaultLocale: "en",
});
```

**Route it as one `[lang]/blog` subtree** - two files, overview + posts. `generateStaticParams` emits every locale (including the default), so `/en/blog/...` is prerendered as the render target for the unprefixed `/blog/...`:

```tsx
// app/[lang]/blog/page.tsx  - overview at /<lang>/blog
import { BlogOverview } from "@daanvandenbergh/scribekit/react";
import { blog } from "./_blog";

export const dynamicParams = false;
export function generateStaticParams() {
    return blog.locales.map((l) => ({ lang: l.code }));
}
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
    return blog.overviewMetadata((await params).lang);
}
export default async function BlogIndex({ params }: { params: Promise<{ lang: string }> }) {
    return <BlogOverview blog={blog} lang={(await params).lang} />;
}
```

```tsx
// app/[lang]/blog/[slug]/page.tsx  - post at /<lang>/blog/<slug>
import { BlogPage } from "@daanvandenbergh/scribekit/react";
import { PostNotFoundError } from "@daanvandenbergh/scribekit";
import { notFound } from "next/navigation";
import { blog } from "../_blog";

export const dynamicParams = false;
export function generateStaticParams() {
    return blog.getPostRefs().map((ref) => ({ lang: ref.lang, slug: ref.slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ lang: string; slug: string }> }) {
    const { lang, slug } = await params;
    try { return blog.postMetadata(blog.getPost(slug, lang)); } catch { return { title: "Post not found" }; }
}
export default async function BlogPostPage({ params }: { params: Promise<{ lang: string; slug: string }> }) {
    const { lang, slug } = await params;
    try { blog.getPost(slug, lang); } catch (err) { if (err instanceof PostNotFoundError) notFound(); throw err; }
    return <BlogPage blog={blog} slug={slug} lang={lang} />;
}
```

**Serve the default language at the clean `/blog` with a `proxy.ts`** (project root; on Next <= 15 name it `middleware.ts` and export `middleware`). It rewrites `/blog/...` onto `/en/blog/...` (URL bar stays `/blog/...`) and redirects `/en/blog/...` back to `/blog/...`, so the default locale has one canonical, unprefixed URL:

```ts
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";

const DEFAULT_LOCALE = "en"; // hard-coded: the proxy runs on the edge; the Blog class is server-only

export function proxy(request: NextRequest) {
    const { pathname } = request.nextUrl;
    const prefix = `/${DEFAULT_LOCALE}`;
    if (pathname === `${prefix}/blog` || pathname.startsWith(`${prefix}/blog/`)) {
        const url = request.nextUrl.clone();
        url.pathname = pathname.slice(prefix.length);      // /en/blog/x -> /blog/x  (redirect)
        return NextResponse.redirect(url, 308);
    }
    if (pathname === "/blog" || pathname.startsWith("/blog/")) {
        const url = request.nextUrl.clone();
        url.pathname = `${prefix}${pathname}`;             // /blog/x -> /en/blog/x  (rewrite)
        return NextResponse.rewrite(url);
    }
}

export const config = { matcher: ["/blog", "/blog/:path*", "/en/blog", "/en/blog/:path*"] };
```

Because the default is reached via a rewrite (rendered on demand), guard the post page's `getPost` with `notFound()` as shown above rather than relying only on `dynamicParams = false`.

> **Prefer no proxy?** Set `prefixDefaultLocale: true`. Every locale is then prefixed, including the default (`/en/blog/...`), so the same `[lang]/blog` tree serves everything with no rewrite - at the cost of `/en/` in the default language's URLs. Add a `next.config.mjs` redirect from bare `/blog` to `/en/blog` if you like.

#### One domain per locale

A site that serves each language on its own domain (`https://example.nl/blog/x`, `https://example.de/blog/x`) passes `localeOrigins` - one origin per configured locale. Then no locale is URL-prefixed (every post lives at `<basePath>/<slug>` on its locale's own domain), and every absolute URL is built on the page locale's origin: the canonical, `og:url`, the hreflang alternates (absolute, so they can cross domains), the JSON-LD `url`/`@id`s and breadcrumbs, `sitemapEntries()` and `rssFeed()`. An origin may carry a sub-path for a locale mounted on another's domain (`en: "https://example.nl/en"`): its pages live under `/en`, while its hero images - `og:image`, `twitter:image`, the JSON-LD `image` - resolve from the host root (`https://example.nl/assets/...`), where public files are served, and so does its `metadataBase` (a relative default `og:image` your layout merges in lands there too, not under `/en`). `siteUrl` defaults to the default locale's origin. With an [i18nkit](https://www.npmjs.com/package/@daanvandenbergh/i18nkit) `strategy: "domain"` instance, pass its `domains`:

```ts
export const blog = new Blog({
    contentDir: "./blog",
    brandName: "Example",
    locales: i18n.list.map((l) => ({ code: l.code, label: l.label, dateLocale: l.locale })),
    defaultLocale: i18n.default,
    localeOrigins: i18n.domains, // { nl: "https://example.nl", de: "https://example.de", ... }
});
```

The app keeps one internal `app/[lang]/blog` tree and its middleware rewrites each domain's bare `/blog/...` into it (i18nkit's docs show that middleware). Every locale needs an origin; the constructor throws on a missing or unknown locale, a non-`http(s)` value, or `localeOrigins` combined with `prefixDefaultLocale`. On localhost, where the other domains are unreachable, bare links would all land on the default locale - to browse every language in dev, leave `localeOrigins` unset there (e.g. `localeOrigins: process.env.NODE_ENV === "development" ? undefined : i18n.domains`, with `siteUrl` set) so the blog falls back to `/<code>/blog/...` prefixes. A language switcher on a domain-per-locale site links to absolute URLs (i18nkit's `i18n.localeUrl(path, code)`). `localeOrigins` is blog-only: `new Docs` rejects it.

`BlogOverview`, `BlogPage`, and the docs components take a `lang` prop, build links via the shared `localePath` (so they can't drift from the canonical/hreflang metadata), and format dates in each language's `dateLocale`.

**Built-in UI copy** (search placeholder, "load more", "min read", "on this page", …) auto-translates to the `lang`, covering the EU's 24 official languages and falling back to English - so a `/fr/blog` page reads in French with no extra wiring. Every label prop still overrides its translation. The copy is powered by [`@daanvandenbergh/i18nkit`](https://www.npmjs.com/package/@daanvandenbergh/i18nkit), whose type-safe catalog guarantees every string is translated at compile time. The resolvers and catalog are exported from `/react` (`blogLabels`, `docsLabels`, `CATALOG`, and the `ui` i18nkit instance) if you want to read a label or add a language.

**The components do not render a language picker** (blog) - placement and styling vary too much to bake in. It's a few lines with `blog.getTranslations(slug)`, `blog.slugFor(slug, lang)` (each language's own URL slug - the folder name unless a file declares `slug:`) and the exported `localePath` (docs get `DocsLanguagePicker` in the navbar for free):

```tsx
import { localePath } from "@daanvandenbergh/scribekit";

function LanguageSwitcher({ slug, current }: { slug: string; current: string }) {
    return (
        <nav aria-label="Language">
            {blog.getTranslations(slug).map((code) => (
                <a key={code} hrefLang={code}
                   href={localePath({ basePath: blog.site?.basePath, defaultLocale: blog.defaultLocale, prefixDefaultLocale: blog.prefixDefaultLocale, trailingSlash: blog.trailingSlash, lang: code, slug: blog.slugFor(slug, code) ?? slug })}
                   aria-current={code === current ? "true" : undefined}>
                    {blog.locales.find((l) => l.code === code)?.label ?? code}
                </a>
            ))}
        </nav>
    );
}
```

> Set the page's `<html lang>` per request from your own layout - that's your app chrome, not the package's job.

### ⚠️ `linkComponent` must render `href` as given

**Never pass a locale-rewriting `<Link>` wrapper as `linkComponent`.** Every href scribekit hands a link component is **already final**: it is built by `localePath`, so the locale is resolved (`/docs/<slug>` on the default language, `/nl/docs/<slug>` on Dutch). The contract is *render this href verbatim* - `next/link` and a bare `<a>` honour it.

Most i18n apps have a `<LocaleLink>` that prefixes the active locale onto an href, and passing it here is the natural instinct. It is a bug, and a nasty one, because it **looks like it works**:

```tsx
// ❌ Wrong - a link wrapper that rewrites hrefs
<DocsSidebar nav={nav} linkComponent={LocaleLink} />

// ✅ Right - render the href exactly as scribekit built it
<DocsSidebar nav={nav} linkComponent={Link} />
```

Re-prefixing an *already*-prefixed href is usually a no-op, so every sidebar, tab, card, and search hit navigates fine. The one thing it breaks is the **language picker** - the only component that emits an href for a locale **other than the active one**. On a Dutch page, `DocsLanguagePicker`'s English entry is the bare `/docs/<slug>`; a rewriting wrapper turns it back into `/nl/docs/<slug>`, so the reader clicks "English", stays in Dutch, and can never leave. Silent, and it disables precisely the control meant to fix it.

The rule: an href **you** author bare (`/pricing`, `homeHref`) gets your locale-aware link; an href **scribekit** hands you gets a plain one.

> **Cookie-based locale preference? Pass `onSelect`.** If your middleware resolves a bare URL's language from a stored preference (a cookie or `Accept-Language`), a plain link **cannot** switch *to* the default locale: navigating to the unprefixed `/docs/<slug>` gets 307'd straight back to the old stored language, so the reader clicks "English", stays in Dutch, and can never leave. Give `DocsLanguagePicker` an **`onSelect`** and it hands you the chosen code instead of navigating (the click is `preventDefault`ed), so you can write the preference **first** and then route. Wire it to your locale setter - with [i18nkit](https://www.npmjs.com/package/@daanvandenbergh/i18nkit) that is one line, since `useSetLocale()` writes the cookie synchronously and its provider does the navigation:
>
> ```tsx
> const setLocale = useSetLocale();
> <DocsLanguagePicker locales={docs.locales} currentLang={lang} onSelect={setLocale} /* … */ />
> ```
>
> The `href`/`hrefLang` stay on each anchor either way, so the links remain crawlable. Leave `onSelect` unset on a purely URL-routed site and the links navigate on their own, as before.

**i18n sitemap.** `blog.sitemapEntries()` (and `docs.sitemapEntries()`) returns one entry per `(slug, lang)` with its absolute URL, hreflang `alternates.languages` (every translation plus `x-default`, each under its own language's slug) and - for blog posts - `lastModified` (front-matter `updated`, else `date`) - assignable straight to a Next.js `sitemap.ts` (needs `siteUrl`/`brandName`):

```ts
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { blog } from "./[lang]/blog/_blog";

export default function sitemap(): MetadataRoute.Sitemap {
    return blog.sitemapEntries();
}
```

See [`demo/`](./demo) for a complete example: every post has a `fr.mdx` beside its `en.mdx`, the single `[lang]/blog` tree lives under `demo/app/`, and `demo/proxy.ts` serves the default language at the clean `/blog`.

---

## Theming

Every colour is a CSS custom property with a sensible light-theme default. Override any from your own CSS - no config, no build step:

```css
:root {
    --scribekit-primary: #6d5df6;      /* links, hover accents, "read more" */
    --scribekit-ink: #0b1b36;          /* headings and body text */
    --scribekit-ink-muted: #4a5568;    /* dates, descriptions */
    --scribekit-surface: #ffffff;      /* card background */
    --scribekit-border: #e5eaf0;       /* card/hr/table borders */
    --scribekit-bg-subtle: #f7f9fc;    /* code blocks, table headers */
    /* --scribekit-overview-width, --scribekit-post-width, --scribekit-card-shadow, --scribekit-font-mono ... */
}
```

Class names are namespaced `.scribekit-*` and MDX body tags are scoped under `.scribekit-prose`, so nothing leaks into the rest of your site. Fonts are inherited from your app.

**Docs** reuse these tokens plus a few of their own (all with defaults): `--scribekit-docs-nav-width`, `--scribekit-doc-width`, `--scribekit-docs-toc-width` (the three column widths), and `--scribekit-code-bg` / `--scribekit-code-ink` (the dark code slab). Docs classes are namespaced `.scribekit-docs-*` / `.scribekit-doc*`.

---

## MDX plugins

**GFM is on by default.** `BlogPage` and `DocsPage` render MDX with [`remark-gfm`](https://github.com/remarkjs/remark-gfm) already applied, so **tables**, strikethrough, task lists, and autolinks just work - along with headings, lists, links, blockquotes, inline code, and fenced code blocks, all styled out of the box. (Pipe tables are a GFM extension, not core Markdown: without the plugin a `| a | b |` table does not error, it silently renders as a paragraph of literal `|` characters. That is a content bug you only see on the published page, so it is not left to the consumer to remember.)

To add anything *extra* - syntax highlighting, autolinked headings - pass options through to `next-mdx-remote` (works the same on `BlogPage` and `DocsPage`). **Your plugins are merged with GFM, never replace it:**

```tsx
import rehypePrettyCode from "rehype-pretty-code";

<BlogPage
    blog={blog}
    slug={slug}
    mdxOptions={{ mdxOptions: { rehypePlugins: [rehypePrettyCode] } }}   // gfm stays enabled
    components={{ pre: MyCodeBlock }}
/>;
```

**Compiling is memoized.** A body already compiled in this process is not compiled again - the cache is keyed on the MDX source text *and* your plugin set, so a page edited under `next dev` is a different key and always renders fresh, and two different plugin sets never share an entry.

---

## API reference

### `new Blog(config)`

The site attributes are passed directly (flattened) into the config:

| Config | Default | Description |
| --- | --- | --- |
| `contentDir` | *(required)* | Folder of `<slug>/<lang>.mdx` posts (one folder per post; default `en.mdx`). Relative to `process.cwd()`, or absolute. |
| `siteUrl` / `brandName` | - | Site origin + brand name. Provide **both** to enable the SEO methods and JSON-LD. |
| `defaultAuthor` | `brandName` | Author used when a post omits its own. |
| `basePath` | `/blog` | Route the blog is mounted at (used to build URLs). |
| `description` | - | Blog index meta description, also the RSS channel description and the `CollectionPage` description. A `string` is used in every locale; a `{ en: "...", nl: "..." }` map gives each locale its own. |
| `indexName` | `` `${brandName} Blog` `` (bare `brandName` for the RSS channel `<title>`) | Human-readable index name: RSS channel title, `CollectionPage` name, index OpenGraph title. Takes the same per-locale map as `description`. |
| `titleTemplate` | `` `%s \| ${brandName}` `` | Template for every page `<title>` (posts and index), `%s` = the page's own title - e.g. `"%s - Example"` to match the host site's separator. |
| `indexTitle` | `"Blog"` | The index page's own title, put through `titleTemplate` - e.g. a keyword title per locale. Changes only the `<title>` (the OpenGraph title stays `indexName`). Takes the same per-locale map as `description`. |
| `homeLabel` | `"Home"` | Name of the first `BreadcrumbList` crumb (the locale's home) in the post, index and docs JSON-LD. Takes the same per-locale map as `description`. |
| `extension` | `.mdx` | Post file extension (each file is `<slug>/<lang><ext>`, e.g. `en.mdx` / `fr.mdx`). |
| `locale` | `en-GB` | Locale for `formatDate` (default language). |
| `locales` | - | Languages this blog is published in; see [Multiple locales](#multiple-locales). Leave unset for a single-language blog. |
| `defaultLocale` | first `locales` code, else base subtag of `locale` | The default language (the `x-default` hreflang target), served unprefixed unless `prefixDefaultLocale`. |
| `prefixDefaultLocale` | `false` | When `true`, the default locale is URL-prefixed too (`/en/blog/<slug>`), so every locale routes through one `[lang]` segment. |
| `localeOrigins` | - | One origin per locale for a [domain-per-locale](#one-domain-per-locale) site (`{ nl: "https://example.nl", de: "https://example.de" }`): no locale is prefixed and every absolute URL uses the page locale's origin. `siteUrl` then defaults to the default locale's origin. |
| `trailingSlash` | `true` | Whether every built URL ends in a slash (`/blog/<slug>/`, `/blog/`). **Must match your `next.config` `trailingSlash`** - a mismatch is silent and site-wide, pointing every canonical, hreflang, sitemap entry and link at the form your host doesn't serve. Next's own default is `false`, so either set `trailingSlash: true` in `next.config` (what the [GitHub Pages setup](#deploy-to-github-pages) does) or `trailingSlash: false` here. The site root stays `/` either way. |
| `organizationId` | - | `@id` of an `Organization` in your site-wide JSON-LD. When set, `BlogPosting.publisher` and the overview `CollectionPage.publisher` reference it by `@id` instead of inlining a duplicate. |
| `authorId` | - | `@id` of a `Person`/`Organization` your site defines as the blog author. When set, `BlogPosting.author` references it by `@id` instead of inlining a name-only `Organization`. |
| `websiteId` | - | `@id` of a `WebSite` your site defines. When set, `BlogPosting` and the overview `CollectionPage` gain an `isPartOf` reference to it. |
| `twitterSite` | - | Your site's X (Twitter) handle, `@` included (`@example`). Emitted as `twitter:site` on every post and index card; unset emits no attribution. |

**Stitch into an existing site graph (better SEO).** If your app already emits site-wide schema.org JSON-LD with stable `@id`s, pass `organizationId` / `authorId` / `websiteId` so the blog references those entities by `@id` instead of inlining its own copies - search engines then merge the blog into your single knowledge-graph entity. Leave them unset to keep the self-contained default output.

Methods: `getPostSlugs()` (the folder names - the translation keys), `getPost(slug, lang?)` (that language's URL slug; throws `PostNotFoundError`), `getAllPosts(lang?)` (newest first), `getAllCategories(lang?)`, `formatDate(iso, lang?)`, `readingMinutes(post)`, `tableOfContents(post)`, `similarPosts(post, limit?)`, and - when `siteUrl`/`brandName` are set - `overviewMetadata(lang?)`, `postMetadata(post)`, `overviewJsonLd(posts, lang?)`, `postJsonLd(post)`, `sitemapEntries()`. Multi-language: `getPostRefs()` (every `(slug, lang)` pair with each language's URL slug, for `generateStaticParams`), `getTranslations(slug)` (folder or any language's slug), `slugFor(slug, lang)` (that post's URL slug in `lang`, or `undefined` - see [per-language slugs](#per-language-slugs)), `dateLocale(lang?)`, `ogLocale(lang?)` (that language's `og:locale` tag - the same value the metadata builders emit, for a host app that hand-builds an OpenGraph block). The assembled config is exposed as `blog.site` / `blog.locale` / `blog.locales` / `blog.defaultLocale` / `blog.prefixDefaultLocale` / `blog.localeOrigins` / `blog.trailingSlash`. The pure helpers behind the components (`readingMinutes`, `tableOfContents`, `similarPosts`, `slugify`, `collectCategories`, `localePath`) are also exported from the package root.

### `<BlogOverview>` / `<BlogPage>`

Both take the `blog` instance and derive their data, date locale, and JSON-LD from it. `BlogOverview` also accepts `posts` to override the auto-derived list; `BlogPage` takes the `slug` (loading the post and 404-ing on an unknown one). Both accept `lang` for [multi-locale](#multiple-locales) blogs (defaults to `blog.defaultLocale`).

Shared optional props: `basePath` (defaults to `blog.site.basePath`), `imgComponent` (default `"img"` - pass `next/image`), `linkComponent` (default `"a"` - pass `next/link`; **it must render `href` as given** - never a locale-rewriting wrapper, see [the contract](#️-linkcomponent-must-render-href-as-given)). `BlogOverview` also takes `header`, `emptyLabel`, `readMoreLabel`, plus the [search, filter & pagination props](#overview-search-filters--pagination). `BlogPage` also takes `components`, `mdxOptions`, `showBackLink`, `backLabel`, plus the [sidebar props](#post-sidebar).

> **Next 16 (Turbopack):** `next/link` / `next/image` imported in a server component are no longer serializable client references, so passing them to `BlogOverview` (whose grid is a client component) fails the build with *"Functions cannot be passed directly to Client Components"*. Re-export them from a `"use client"` file and pass that instead:
>
> ```tsx
> // app/blog/_link.tsx
> "use client";
> export { default as Link } from "next/link";
> ```

### `new Docs(config)`

Same flattened site attributes as [`new Blog(config)`](#new-blogconfig) (`siteUrl` / `brandName` / `defaultAuthor` / `description` / `indexName` / `titleTemplate` / `indexTitle` / `homeLabel` / `organizationId` / `authorId` / `websiteId` / `twitterSite` / `locale` / `locales` / `defaultLocale` / `prefixDefaultLocale` / `trailingSlash`; `localeOrigins` is blog-only and makes `new Docs` throw), plus:

| Config | Default | Description |
| --- | --- | --- |
| `contentDir` | *(required)* | Folder of `<slug>/<lang>.mdx` docs (one folder per page; default `en.mdx`). Relative to `process.cwd()`, or absolute. |
| `basePath` | `/docs` | Route the docs are mounted at (used to build URLs). Set `""` to mount at the site root, so pages are `/<slug>` and the index is `/`. |
| `tabs` | - | Display order + labels for the top-level tabs. Each entry is a tab id (matching a page's `tab`) or `{ id, label }`. Purely presentational. |
| `groups` | - | Display order + labels for the sidebar group headings, same semantics as `tabs`. |
| `redirects` | - | Old slug → new slug for [renamed pages](#renaming-a-page-redirects), e.g. `{ "getting-started": "quickstart" }`. A real page always wins over an entry with the same slug; chains resolve in one hop; a cycle 404s instead of looping. |

Methods: `getDocSlugs()`, `getDocRefs()` (every `(slug, lang)` pair), `getRedirect(slug, lang?)` (the 308 destination URL for a renamed slug, else `undefined`), `getRedirectRefs()` (every redirected `(slug, lang)` pair - spread it into `generateStaticParams`), `getDoc(slug, lang?)` (throws `DocNotFoundError`), `getAllDocs(lang?)`, `getTranslations(slug)`, `getNavTree(lang?)`, `getBreadcrumb(slug, lang?)`, `getAdjacent(slug, lang?)` (prev/next), `tableOfContents(doc)`, `readingMinutes(doc)`, `formatDate(iso, lang?)`, `dateLocale(lang?)`, and - when `siteUrl`/`brandName` are set - `docMetadata(doc)`, `indexMetadata(lang?)`, `docJsonLd(doc)`, `indexJsonLd(lang?)`, `sitemapEntries()`. The config is exposed as `docs.site` / `docs.locale` / `docs.locales` / `docs.defaultLocale` / `docs.prefixDefaultLocale` / `docs.trailingSlash`.

### `<DocsNavbar>` / `<DocsTabs>` / `<DocsSidebar>` / `<DocsToc>` / `<DocsPage>` / `<DocsIndex>`

Every component below takes `linkComponent` (default `"a"` - pass `next/link`). **It must render `href` as given**: scribekit has already resolved the locale, so a locale-rewriting `<Link>` wrapper silently breaks the language picker - see [the contract](#️-linkcomponent-must-render-href-as-given).

- **`DocsSearchProvider`** (client) - owns the shared ⌘K command palette; wrap your docs shell in it. Props: `nav`, `lang`, `linkComponent`, `searchPlaceholder`, `searchEmptyLabel`, `renderIcon`. Any descendant opens it via **`useDocsSearch().open()`**, or drop a **`DocsSearchButton`** (`placeholder`, `lang`, `className`).
- **`DocsNavbar`** (client, optional) - the top bar. Props: `logo`, `logoSize` (default 22), `brandName`, `docsText` (the pill, default `"Docs"`; `""`/`null` hides it), `homeHref`, `linkComponent`, `lang`, `showSearch` (default `true`), `searchPlaceholder`, **`actions`** (the right-hand section - the part most sites edit), and **`languagePicker`** (a slot before the actions). `actions` takes **arbitrary nodes**: drop in `DocsNavbarButton`s, or your own site's CTA component. A link that carries a class of its own keeps its own styling; only a genuinely raw, unstyled `<a>` picks up the navbar's muted fallback colour.
- **`DocsNavbarButton`** (optional) - a navbar action: `variant` `"link"` / `"primary"` / `"secondary"`. Props: `href` (renders a link, else a `<button>`) / `onClick`, `variant`, `linkComponent`, `icon`, `target`, `rel`, `ariaLabel`. Pass a list to `DocsNavbar`'s `actions`.
- **`DocsLanguagePicker`** (client, optional) - the navbar language switcher: a flag-and-caret trigger opening a menu of the docs' languages, each a `hreflang` link. **Auto-hides for single-locale docs**, so you can always render it. Flags come from [i18nkit](https://www.npmjs.com/package/@daanvandenbergh/i18nkit)'s `localeFlag` (override with `renderFlag`). Props: `locales`, `currentLang`, `activePath`, `basePath`, `defaultLocale`, `prefixDefaultLocale`, `trailingSlash`, `linkComponent`, **`onSelect`** (`(code) => void` - takes over the switch instead of navigating; **required if your middleware resolves a bare URL's language from a cookie**, see [above](#️-linkcomponent-must-render-href-as-given)), `lang`, `renderFlag`, `changeLanguageLabel`, `headingLabel`. Pass it to `DocsNavbar`'s `languagePicker`.
- **`DocsTabs`** (client) - the top tab bar (underline tabs + sliding indicator); each tab links to its section's first page. Props: `nav`, `activePath`, `linkComponent`, `lang`, `label`. Renders nothing for a single-tab tree.
- **`DocsSidebar`** (client) - the left nav (active tab's groups) + ⌘K palette. Props: `nav`, `activePath`, `linkComponent`, `lang`, `renderIcon`, and label overrides (`searchPlaceholder`, `searchEmptyLabel`, `label`).
- **`DocsToc`** (client) - the right "on this page" minimap with scroll-spy. Rendered by `DocsPage`. Props: `toc`, `title`.
- **`DocsPage`** (server) - one page (article + `DocsToc`). Props: `docs`, `slug`, `lang`, `linkComponent`, `components` / `mdxOptions`, `showToc` / `showBreadcrumb` / `showFeedback`, `onFeedback`, and label overrides (`tocTitle`, `previousLabel`, `nextLabel`, `feedbackQuestion`, `readingLabel`, …).
- **`DocsIndex`** (server) - the landing page. Props: `docs`, `lang`, `linkComponent`, `title`, `description`, `header` (replace the hero), `renderIcon`.

The built-in UI copy (search, prev/next, "was this helpful?", "on this page", "updated …") is translated into 24 EU languages and picked by `lang`; every string is overridable via the props above.

---

## Notes

- **Static by default.** With `generateStaticParams` + `dynamicParams = false`, posts and docs are prerendered at build time (SSG). If you instead read at request time with `output: "standalone"`, pass an **absolute** `contentDir` and add the content dir to `outputFileTracingIncludes` so the files ship with your server bundle.
- **Reads are cached, and the cache checks the file.** Each content file is read and normalised once per `Blog`/`Docs` instance, then re-used until its modification time or size changes - so a build does not re-parse the whole corpus for every page. The check is a `stat` per read, which costs a fraction of the work it skips, and it means there is nothing to configure or clear: `next dev` picks up an edit immediately, and a long-lived `output: "standalone"` server picks up content that changes underneath it.
- **Server-only classes.** `Blog` and `Docs` read the filesystem - keep them in server components / route files and pass the resulting data to the components as props (exactly what the snippets above do). `DocsTabs` / `DocsSidebar` / `DocsToc` are client components, so they take the already-computed `nav` tree / `toc` as props.
