# Overview
A customizable MDX docs framework for Next.js.
PzzaDocs turns a folder of MDX files into a complete documentation site inside your existing Next.js app. You install one package, add four route files, and get a sticky header with navigation tabs, a collapsible sidebar, a table of contents, breadcrumbs, prev/next pagination, command-palette search, and light/dark theming out of the box.
Everything is styled with plain CSS custom properties prefixed `--pd-`, so you can restyle the whole site from your own `globals.css` without touching the package or adopting a utility framework.
## How it works [#how-it-works]
- **Content lives in your repo.** Pages are `.mdx` (or `.md`) files under `content/docs`. Folder order, labels, and section separators come from a small `meta.json` per folder.
- **Rendering happens on the server.** `createSource` reads your content, compiles MDX at request time with full React Server Component support, highlights code with dual light/dark themes, and caches the result per process.
- **The UI is a set of composable components.** `DocsLayout` and `DocsPage` from `pzzadocs/ui` assemble the shell, while `pzzadocs/mdx` ships the Callout, Tabs, Steps, Cards, Accordion, and code block components that are available in every page without imports.
## Explore the docs [#explore-the-docs]
## More [#more]
## Requirements [#requirements]
| Requirement | Version |
| --- | --- |
| Next.js | 16 (App Router) |
| React | 19 |
| Node.js | 24 or newer |
| TypeScript | 5.9 (recommended, not required) |
`createSource` reads the filesystem and compiles MDX, so it must run on the server. Keep `lib/source.ts` out of client components and import it only from route files, layouts, and route handlers.
---
# Installation
Add PzzaDocs to a Next.js 16 app and mount the docs routes.
PzzaDocs is a regular npm package. It plugs into an existing Next.js App Router project; you do not need a template or a separate build step.
## Prerequisites [#prerequisites]
- A Next.js 16 project using the App Router (`app/` directory).
- React 19 and React DOM 19. Both are peer dependencies of PzzaDocs and are not installed for you.
- Node.js 24 or newer.
## Install the package [#install-the-package]
```package-install
npm install pzzadocs
```
The tabs above are generated from a single `package-install` fence; the choice is remembered across every install block on the site. The package declares `next`, `react`, and `react-dom` as peer dependencies. If your project already has them, nothing else is required. The MDX compiler, syntax highlighter, and theme provider are bundled as regular dependencies.
## Set up the project [#set-up-the-project]
### Create the config file
Put a `docs.config.ts` at the root of your project. `defineConfig` type-checks the object and fills in defaults for `contentDir`, `baseUrl`, `search`, and `theme`.
```ts tab="TypeScript" title="docs.config.ts"
import { defineConfig } from "pzzadocs";
export default defineConfig({
title: "My Docs",
description: "Documentation for my project.",
contentDir: "content/docs",
baseUrl: "/docs",
nav: {
links: [{ text: "Overview", href: "/docs" }],
github: "https://github.com/your-org/your-repo",
},
editUrl: (page) => `https://github.com/your-org/your-repo/edit/main/content/docs/${page.file}`,
});
```
```js tab="JavaScript" title="docs.config.mjs"
import { defineConfig } from "pzzadocs";
export default defineConfig({
title: "My Docs",
description: "Documentation for my project.",
contentDir: "content/docs",
baseUrl: "/docs",
nav: {
links: [{ text: "Overview", href: "/docs" }],
github: "https://github.com/your-org/your-repo",
},
editUrl: (page) => `https://github.com/your-org/your-repo/edit/main/content/docs/${page.file}`,
});
```
Adjacent fences with a `tab` attribute merge into one tabbed block, which is how the two variants above are rendered.
### Create the content source
The source is the server-side object that reads and compiles your MDX. Create it once and import it wherever you need pages.
```ts title="lib/source.ts"
import { createSource } from "pzzadocs";
import config from "@/docs.config";
export const source = createSource(config);
```
### Add the root layout
Load your fonts with `next/font/google`, expose them as the `--pd-font-sans` and `--pd-font-mono` CSS variables, import the stylesheet, and wrap the app in `RootProvider` with the `theme` and `search` sections of your config. `suppressHydrationWarning` on `` is required because the theme class is applied on the client before React hydrates.
```tsx title="app/layout.tsx"
import type { ReactNode } from "react";
import { Geist, Geist_Mono } from "next/font/google";
import { RootProvider } from "pzzadocs/ui";
import "pzzadocs/styles.css";
import config from "@/docs.config";
const sans = Geist({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--pd-font-sans",
});
const mono = Geist_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: "--pd-font-mono",
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
{children}
);
}
```
### Add the docs layout
`DocsLayout` renders the header, the sidebar, and the mobile drawer. It needs the page tree from the source and your config.
```tsx title="app/docs/layout.tsx"
import type { ReactNode } from "react";
import { DocsLayout } from "pzzadocs/ui";
import config from "@/docs.config";
import { source } from "@/lib/source";
export default async function Layout({ children }: { children: ReactNode }) {
const tree = await source.getPageTree();
return (
{children}
);
}
```
### Add the catch-all page
One optional catch-all route serves every doc. `generateStaticParams` pre-renders all pages at build time, `generateMetadata` reads the frontmatter, and `notFound()` handles unknown slugs. `DocsPage` derives breadcrumbs from `tree` and `url`, renders the TOC from `toc`, builds the prev/next footer from `neighbours`, and shows the page actions menu (copy Markdown, open in GitHub, open in an assistant) when `editUrl` or `markdownUrl` is set.
```tsx title="app/docs/[[...slug]]/page.tsx"
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "pzzadocs/ui";
import config from "@/docs.config";
import { source } from "@/lib/source";
interface PageProps {
params: Promise<{ slug?: string[] }>;
}
export async function generateStaticParams() {
const pages = await source.getPages();
return pages.map((page) => ({ slug: page.slug }));
}
export async function generateMetadata({ params }: PageProps): Promise {
const { slug = [] } = await params;
const page = await source.getPage(slug);
if (!page) {
return {};
}
return {
title: page.title,
description: page.description,
};
}
export default async function Page({ params }: PageProps) {
const { slug = [] } = await params;
const page = await source.getPage(slug);
if (!page) {
notFound();
}
const [tree, neighbours] = await Promise.all([source.getPageTree(), source.getNeighbours(slug)]);
return (
{page.title}{page.description}{page.content}
);
}
```
### Add the search index route
The search dialog fetches a JSON index once and filters it on the client. Serve it from a route handler. Marking it `force-static` lets Next.js emit it as a static file at build time.
```ts title="app/api/search/route.ts"
import { source } from "@/lib/source";
export const dynamic = "force-static";
export async function GET() {
return Response.json(await source.getSearchIndex());
}
```
### Write your first page
Create `content/docs/index.mdx`. The `title` field is required; everything else is optional.
```mdx title="content/docs/index.mdx"
---
title: Welcome
description: The first page of your documentation.
---
## Hello
This page is rendered from MDX by PzzaDocs.
```
Start the dev server and open `/docs`.
### Optional: Markdown and llms.txt routes
The page actions menu links to a raw Markdown version of each page, and assistants can discover the whole site through `llms.txt`. Add three route handlers; see [LLM outputs](/guides/llm) for the details and the rewrite that serves them at the default `.mdx` URLs.
```ts title="app/llms.txt/route.ts"
import { source } from "@/lib/source";
export const dynamic = "force-static";
export async function GET() {
return new Response(await source.getLlmsTxt(), {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
```
`docs.config.ts` is a plain module. It can live anywhere; the examples above import it with the `@/` alias, which Next.js maps to the project root when `paths` is set in `tsconfig.json`.
## Next steps [#next-steps]
---
# Project Structure
Where files live in a PzzaDocs project and what each one is responsible for.
A finished setup touches six files in your app plus a content folder. Nothing is hidden: every route is a file you own and can edit.
## File tree [#file-tree]
```text
my-app/
├── app/
│ ├── layout.tsx # fonts, stylesheet, RootProvider
│ ├── page.tsx # optional: redirect / to /docs
│ ├── api/
│ │ └── search/
│ │ └── route.ts # JSON search index
│ └── docs/
│ ├── layout.tsx # DocsLayout (header + sidebar)
│ └── [[...slug]]/
│ └── page.tsx # DocsPage for every doc
├── content/
│ └── docs/
│ ├── meta.json # top-level ordering
│ ├── index.mdx # /docs
│ └── guides/
│ ├── meta.json # folder label and ordering
│ ├── index.mdx # /docs/guides
│ └── setup.mdx # /docs/guides/setup
├── docs.config.ts # defineConfig
└── lib/
└── source.ts # createSource(config)
```
## Responsibilities [#responsibilities]
| File | Role |
| --- | --- |
| `docs.config.ts` | The single configuration object. Consumed by `createSource`, `RootProvider`, and `DocsLayout`. |
| `lib/source.ts` | Creates the content source. Server-only; never import it from a client component. |
| `app/layout.tsx` | Loads fonts, imports `pzzadocs/styles.css`, and wraps the tree in `RootProvider` (theme and search dialog state). |
| `app/docs/layout.tsx` | Renders `DocsLayout` with the page tree. Everything under `/docs` inherits the header and sidebar. |
| `app/docs/[[...slug]]/page.tsx` | Resolves the slug to a page, builds metadata, and renders `DocsPage`. |
| `app/api/search/route.ts` | Returns `getSearchIndex()` as JSON for the search dialog. |
| `content/docs/**` | Your MDX pages and `meta.json` files. |
## URL mapping [#url-mapping]
Routes are derived from file paths relative to `contentDir`, with `baseUrl` prepended:
| File | URL |
| --- | --- |
| `content/docs/index.mdx` | `/docs` |
| `content/docs/installation.mdx` | `/docs/installation` |
| `content/docs/guides/index.mdx` | `/docs/guides` |
| `content/docs/guides/setup.mdx` | `/docs/guides/setup` |
The slug passed to `getPage` is the array of path segments after `baseUrl`, so `/docs/guides/setup` becomes `["guides", "setup"]` and `/docs` becomes `[]`.
## Mounting under a different path [#mounting-under-a-different-path]
`baseUrl` and the route folder must agree. To serve docs from `/handbook` instead of `/docs`:
1. Rename `app/docs` to `app/handbook`.
2. Set `baseUrl: "/handbook"` in `docs.config.ts`.
3. Update `nav.links` so the tab hrefs point at the new prefix.
The content folder does not need to move; `contentDir` is independent of `baseUrl`.
## Deployment [#deployment]
PzzaDocs has no runtime requirements beyond Next.js itself, so it deploys anywhere Next.js does.
- **Static generation.** `generateStaticParams` returns every page from `getPages()`, so all docs are pre-rendered at build time. The search route is marked `force-static` and becomes a JSON file in the build output.
- **Content reads at build time.** `createSource` reads `contentDir` relative to `process.cwd()`. Make sure the content folder is included in your deployment and that the build runs from the project root.
- **Caching.** Compiled pages are cached in memory keyed by file path and modification time. In development, editing an MDX file invalidates its entry on the next request. In production the cache is filled during the build.
- **Edit links.** If you set `editUrl` in the config, each page footer links to the page's source file. Pair it with your repository URL so readers can open pull requests against the docs.
On platforms that trace file dependencies for serverless functions, confirm the `content/docs` folder is shipped with the function bundle. Because every page is statically generated, this only matters if you opt a route out of static rendering.
---
# Configuration
Every field of the docs.config.ts file and what it controls.
All configuration lives in one object passed to `defineConfig`. The helper fills in defaults for the optional fields and returns a `ResolvedDocsConfig`, so typos and missing required fields are caught at compile time and the UI never has to guess.
```ts title="docs.config.ts"
import { defineConfig } from "pzzadocs";
export default defineConfig({
title: "PzzaDocs",
description: "A customizable MDX docs framework for Next.js.",
contentDir: "content/docs",
baseUrl: "/docs",
nav: {
links: [
{ text: "Overview", href: "/docs" },
{ text: "Components", href: "/docs/components/callout" },
{ text: "API", href: "/docs/api/config" },
],
github: "https://github.com/pzzaworks/pzza-docs",
},
search: { enabled: true },
theme: { defaultTheme: "system" },
});
```
## Fields [#fields]
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `string` | required | Site name. Rendered as the wordmark next to the logo in the header. |
| `description` | `string` | none | Short description of the site. Useful as a default for page metadata. |
| `logo` | `ReactNode \| { src: string; alt?: string; width?: number; height?: number }` | none | Header logo. Pass a React element for full control, or an image descriptor. |
| `contentDir` | `string` | `"content/docs"` | Folder containing your MDX, relative to `process.cwd()`. |
| `baseUrl` | `string` | `"/docs"` | Route prefix where `DocsPage` is mounted. Normalized to a leading slash and no trailing slash. |
| `nav.links` | `{ text: string; href: string; activePrefix?: string; external?: boolean }[]` | `[]` | Horizontal tabs in the header. A tab is active when the current path starts with `activePrefix`, which defaults to the `href`. `external` is inferred from the href when omitted. |
| `nav.github` | `string` | none | Repository URL. Renders a GitHub icon link on the right side of the header. |
| `nav.cta` | `{ text: string; href: string }` | none | Optional outline button at the far right of the header. |
| `editUrl` | `(page: PageData) => string` | none | Builds the "Edit on GitHub" link for a page. Receives the page data. |
| `search.enabled` | `boolean` | `true` | Shows the search trigger in the header and registers the ⌘K shortcut. |
| `search.indexUrl` | `string` | `"/api/search"` | Endpoint the search dialog fetches the JSON index from. |
| `search.hotKey` | `string` | `"k"` | Key combined with Meta or Ctrl that opens the dialog. |
| `theme.defaultTheme` | `"light" \| "dark" \| "system"` | `"system"` | Theme used before the visitor picks one. |
| `theme.hotKey` | `boolean` | `true` | Pressing `D` outside an input toggles light and dark. |
| `toc.minDepth` / `toc.maxDepth` | `number` | `2` / `3` | Heading depth range collected into the table of contents. |
| `llm.enabled` | `boolean` | `true` | Enables the Markdown and llms.txt outputs and the page actions menu. |
| `llm.markdownPath` | `(url: string) => string` | `` (url) => `${url}.mdx` `` | Maps a page URL to the route serving its raw Markdown. |
| `sidebar.defaultOpenLevel` | `number` | `0` | Folders at this depth or shallower start expanded. |
| `sidebar.prefetch` | `boolean` | `true` | Prefetch sidebar links with `next/link`. |
| `header.transparentMode` | `"none" \| "top" \| "always"` | `"none"` | Transparent header until scroll, always, or never. |
| `lastModified` | `"git" \| "fs" \| false` | `"git"` | Source of the "Last updated" timestamp. |
| `defaultLocale` / `locales` | `string` / `string[]` | none / `[]` | The source locale and the translated locales. |
| `localeStrategy` | `"suffix" \| "directory"` | `"suffix"` | Translations as `page.de.mdx` next to the source, or as one directory per locale under `contentDir`. |
| `localePrefix` | `"never" \| "as-needed" \| "always"` | `"never"` | Locale segment before `baseUrl` in page URLs. |
| `translations` | `Record>` | none | UI string overrides per locale. |
## Localization [#localization]
`defaultLocale`, `locales`, `localeStrategy`, `localePrefix`, and `translations` configure translated content and UI strings. See [Localization](/guides/i18n) for the directory layouts, the resulting URLs, and the route wiring.
## Navigation [#navigation]
`nav.links` defines the horizontal tabs. Each tab is a plain link. A tab is active when the current pathname starts with its `activePrefix`, which defaults to the `href`; set `activePrefix` when the tab links to the first page of a section so it stays highlighted across the whole section. The most specific match wins, so an Overview tab at `/docs` does not stay lit on `/docs/api/config`.
```ts
nav: {
links: [
{ text: "Overview", href: "/docs" },
{ text: "Components", href: "/docs/components/callout", activePrefix: "/docs/components" },
{ text: "API", href: "/docs/api/config", activePrefix: "/docs/api" },
],
github: "https://github.com/pzzaworks/pzza-docs",
cta: { text: "Sign in", href: "/login" },
}
```
## Logo [#logo]
Omit `logo` to render the title alone. Pass an image descriptor to show an image before the wordmark:
```ts
logo: { src: "/logo.svg", alt: "pzzadocs" }
```
Or pass any React element:
```tsx
logo: ⌘
```
`docs.config.ts` is imported by server code (`createSource`, `DocsLayout`) and its `theme` and `search` sections are forwarded to the client-side `RootProvider`. If you use a React element as the logo, keep it free of hooks and browser-only APIs so it renders on the server.
## Edit links [#edit-links]
`editUrl` receives the page and returns a URL. The page object includes the slug and the source file path relative to `contentDir`, which is usually all you need:
```ts
editUrl: (page) =>
`https://github.com/pzzaworks/pzza-docs-web/edit/main/content/docs/${page.file}`,
```
When `editUrl` is set, every page footer shows a "Last updated" timestamp followed by the edit link.
## Search [#search]
Search is client-side and enabled by default. The header shows a "Search..." button with a ⌘K hint, and the dialog fetches the index from `search.indexUrl` (default `/api/search`) once, then filters titles, descriptions, and headings as you type. Disable it with `search: { enabled: false }` if you do not want to expose the index route, or point `indexUrl` at a static JSON file you generate at build time.
## Theme [#theme]
`theme.defaultTheme` is forwarded to the theme provider. `"system"` follows the operating system preference and switches automatically. The toggle in the header lets visitors override it, and the choice persists in local storage. See [Theming](/guides/theming) for how to restyle the site.
---
# Writing Content
Frontmatter, the full meta.json grammar, route groups, locales, and heading controls.
Content is a folder of Markdown and MDX files. PzzaDocs reads every `.mdx` and `.md` file under `contentDir`, parses the frontmatter, and compiles the body with full MDX support: JSX, expressions, and GitHub Flavored Markdown tables, task lists, strikethrough, and autolinks.
## Frontmatter [#frontmatter]
Each file starts with a YAML block. `title` is the only required field.
```mdx title="content/docs/guides/setup.mdx"
---
title: Setup
description: Install and configure the CLI.
icon: Wrench
order: 2
full: false
tag: guides
---
## First heading
Body text goes here.
```
Any other keys are preserved on the page object under `frontmatter`, so you can read custom fields from your own route code.
## Folder routes and route groups [#folder-routes-and-route-groups]
A file named `index.mdx` maps to the folder itself: `content/docs/guides/index.mdx` is served at `/docs/guides`.
Folders whose name is wrapped in parentheses, such as `(advanced)`, are **route groups**. They organise the sidebar without appearing in URLs: `content/docs/(advanced)/caching.mdx` is served at `/docs/caching`. Two files that resolve to the same URL throw at build time, so the conflict is caught early. A route group is never a link itself, even when it contains an `index.mdx`: the group renders as a plain section label and its index page is listed as an ordinary entry, so the `(getting-started)/index.mdx` pattern gives you a grouped sidebar whose first item is the docs root.
## Locales [#locales]
Locale variants sit next to the default file with a suffix: `setup.de.mdx` and `meta.de.json`. Declare the locales in the config, then pass the locale to the source methods:
```ts
// docs.config.ts
locales: ["de", "fr"],
defaultLocale: "en",
// in a route
const page = await source.getPage(slug, "de");
```
Missing translations fall back to the default file, so a partially translated site still renders every page. Translations can also live in one directory per locale (`content/docs/en/...`, `content/docs/de/...`) with `localeStrategy: "directory"`, and `localePrefix` puts the locale into page URLs. The [Localization](/guides/i18n) guide covers both strategies, the route wiring, and translated UI strings.
## Ordering with meta.json [#ordering-with-metajson]
Each folder can contain a `meta.json` that labels the folder in the sidebar and controls its children.
```json title="content/docs/guides/meta.json"
{
"title": "Guides",
"description": "Step by step guides.",
"icon": "BookOpen",
"collapsible": true,
"defaultOpen": true,
"root": false,
"pagesIndex": "index",
"pages": [
"index",
"---[Star]Basics---",
"quickstart",
"...advanced",
"!internal",
"[Rocket][Changelog](/changelog)",
"external:[Repository](https://github.com/pzzaworks/pzza-docs)",
"..."
]
}
```
### Folder fields [#folder-fields]
### Entry grammar [#entry-grammar]
| Entry | Meaning |
| --- | --- |
| `"name"` | A page or folder in this directory, by file name without extension. `"index"` is the folder's index page. |
| `"..."` | Every remaining item, sorted by `order` and then by name. |
| `"z...a"` | The same as `"..."` in reverse order. |
| `"!name"` | Excludes `name` from the rest. Use it with `"..."` to hide a page without deleting it. |
| `"...folder"` | The children of `folder` hoisted inline, without the folder itself. |
| `"[Text](url)"` | A link item. `url` can be internal or external. |
| `"[Icon][Text](url)"` | A link item with an icon. |
| `"external:[Text](url)"` | A link item that always opens in a new tab with an external icon. |
| `"---Label---"` | A section label: small uppercase text that does not link anywhere. |
| `"---[Icon]Label---"` | A section label with an icon. |
When `pages` is given, items that are not listed are hidden unless `"..."` appears somewhere in the array.
Folders without a `meta.json` behave as if `pages` were `["..."]`: the index page comes first, then the remaining pages sorted by `order`, then alphabetically.
The sidebar on this site is built from these rules. The Components folder uses `---[Icon]Label---` separators to group pages, and the API Reference folder ends with an `external:` link to the npm package.
## Headings and the table of contents [#headings-and-the-table-of-contents]
Headings receive a stable `id` derived from their text, so `## Install the CLI` can be linked as `#install-the-cli`. Hovering a heading reveals an anchor link. Three suffixes control the behaviour:
| Syntax | Effect |
| --- | --- |
| `## Title [#custom-id]` | Uses `custom-id` as the anchor instead of the generated slug. |
| `## Title [!toc]` | Renders the heading but hides it from the table of contents. |
| `## Title [toc]` | Adds a TOC entry without rendering the heading. Useful when a component renders its own heading. |
The depth range collected into the TOC comes from `toc.minDepth` and `toc.maxDepth` in the config (defaults `2` and `3`).
### A custom anchor in practice [#anchor-demo]
This heading is written as `### A custom anchor in practice [#anchor-demo]`, so its link is `#anchor-demo`.
## Links [#links]
Use absolute paths that include `baseUrl` for internal links. They render through `next/link`, so navigation is client-side and prefetched:
```md
See the [configuration reference](/api/config).
```
## Images [#images]
Plain Markdown images open in a lightbox when clicked. See [Image Zoom](/components/image-zoom).
## Code [#code]
Fenced code blocks are highlighted at compile time with the bundled monochrome themes. The fence meta supports titles, tabs, line numbers, and notation comments for highlighting and diffs. See [Code Blocks](/components/code-blocks) for the full grammar.
## Components in MDX [#components-in-mdx]
All components from `pzzadocs/mdx` are available in every page without an import statement:
```mdx
This operation cannot be undone.
```
Register your own with `createSource(config, { components: { Chart } })`.
## Search index [#search-index]
The search index is built from the frontmatter, headings, and body text of every page, so descriptive titles and a `description` on each page produce better results. See [Search](/guides/search).
---
# Localization
Translated content with the suffix or directory strategy, locale prefixes in URLs, locale-aware routes, and translated UI strings.
Localization has two independent halves. **Content** is the set of translated MDX files and `meta.json` manifests that the source reads per locale. **UI strings** are the labels the framework itself renders, such as "On this page" or "Search documentation", which you override per locale through `translations`. Both are configured in `docs.config.ts` and both fall back to the default: a page that is not translated yet is served from the default locale, and a label that is not translated yet is rendered in English.
```ts title="docs.config.ts"
import { defineConfig } from "pzzadocs";
export default defineConfig({
title: "PzzaDocs",
baseUrl: "/docs",
defaultLocale: "en",
locales: ["de", "fr"],
localeStrategy: "suffix",
localePrefix: "as-needed",
});
```
>", description: "UI string overrides keyed by locale, merged over the English defaults." },
}}
/>
PzzaDocs does not detect the visitor's language, set cookies, or redirect. It reads content per locale and builds URLs with the prefix you asked for; which locale a request maps to is decided by your Next.js routes. That makes it pair with any routing or i18n library, or with a plain `[locale]` segment and nothing else.
## Content strategies [#content-strategies]
### Suffix [#suffix]
The default. A translation sits next to its source file with the locale in the file name: `setup.de.mdx` beside `setup.mdx`, `meta.de.json` beside `meta.json`. Files without a suffix belong to `defaultLocale`.
When the source scans for `de`, `setup.de.mdx` replaces `setup.mdx` and `meta.de.json` replaces `meta.json` in that folder. Files of other locales, such as `setup.fr.mdx`, are skipped. `guides/deploy.mdx` has no German variant, so the German tree serves the English file at the German URL.
```ts
defaultLocale: "en",
locales: ["de", "fr"],
// localeStrategy defaults to "suffix"
```
A suffix is only recognised when it is listed in `locales`. With `locales: ["de"]`, a file named `setup.fr.mdx` is treated as a page with the slug `setup.fr`, not as a French translation.
### Directory [#directory]
One directory per locale under `contentDir`. `defaultLocale` names the source directory, and the other directories hold translations with the same relative paths.
```ts
defaultLocale: "en",
locales: ["de"],
localeStrategy: "directory",
```
The locale directory is an **overlay**, not a separate tree. The source walks the default locale's directory and, for every file and folder, prefers the file at the same relative path under the requested locale. `de/guides/setup.mdx` replaces `en/guides/setup.mdx`; `de/guides/deploy.mdx` does not exist, so the English page is served; `de/guides/meta.json` does not exist, so the English manifest orders the German folder. A file that exists only under `de/` is served for German and nowhere else, so a locale can carry pages the source does not have.
The first path segment is the locale directory and never becomes part of the slug: `content/docs/de/guides/setup.mdx` is served at `/docs/guides/setup` (plus the locale prefix, see below), not at `/docs/de/guides/setup`.
`createSource` throws when `localeStrategy` is `"directory"` and `defaultLocale` is missing, because it cannot know which directory holds the source content.
### Fallback [#fallback]
In both strategies a missing translation falls back to the default locale file, so a half-translated site still renders every page in every locale. The fallback page keeps its URL under the requested locale and reports `locale` as the requested locale on its `PageData`, so prev/next links, breadcrumbs, and the search index stay within one locale.
Passing `defaultLocale` to a source method is the same as omitting the locale: `source.getPage(slug, "en")` and `source.getPage(slug)` return the same page.
## URL prefixes [#url-prefixes]
`localePrefix` decides whether the locale appears in page URLs. The source applies it to every `url` it produces: page data, the sidebar tree, neighbours, the search index, and the llms outputs. With `baseUrl: "/docs"`, `defaultLocale: "en"`, and `locales: ["de"]`, the page `guides/setup` resolves to:
| `localePrefix` | English (default) | German |
| --- | --- | --- |
| `"never"` (default) | `/docs/guides/setup` | `/docs/guides/setup` |
| `"as-needed"` | `/docs/guides/setup` | `/de/docs/guides/setup` |
| `"always"` | `/en/docs/guides/setup` | `/de/docs/guides/setup` |
The locale segment goes **before** `baseUrl`, matching the `app/[locale]/docs` route shape in Next.js. When `baseUrl` is `/`, the URLs are `/de` and `/de/guides/setup`.
`"never"` is for sites that serve one locale per deployment or pick the locale from a domain, a cookie, or a header; the URLs are identical in every locale and your route decides what to render. `"as-needed"` keeps the default locale's URLs clean. `"always"` makes every URL explicit and requires `defaultLocale`; `createSource` throws without it.
## Routes [#routes]
A `[locale]` segment above the docs routes gives every request a locale. Pass it to each source method and to the UI components. The example below uses `localePrefix: "as-needed"` and a plain segment; the default locale is served from the root routes as well, so both `/docs/guides/setup` and `/de/docs/guides/setup` resolve.
A small helper keeps the locale list and the validation in one place:
```ts title="lib/locales.ts"
import config from "@/docs.config";
export const locales = [config.defaultLocale ?? "en", ...config.locales];
export function isLocale(value: string): boolean {
return locales.includes(value);
}
```
### Root layout [#root-layout]
`RootProvider` takes the labels for the locale and the search index URL for that locale. `resolveLabels` merges `translations[locale]` over the English defaults; see [UI strings](#ui-strings) below.
```tsx title="app/[locale]/layout.tsx"
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { resolveLabels } from "pzzadocs";
import { RootProvider } from "pzzadocs/ui";
import "pzzadocs/styles.css";
import config from "@/docs.config";
import { isLocale, locales } from "@/lib/locales";
interface LayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function Layout({ children, params }: LayoutProps) {
const { locale } = await params;
if (!isLocale(locale)) {
notFound();
}
return (
{children}
);
}
```
### Docs layout [#docs-layout]
`getPageTree(locale)` returns the sidebar for that locale with localized titles from `meta.json` and localized URLs. `DocsLayout` takes the same `labels` for the parts it renders on the server.
The static parts of the config are written for the default locale, so derive a copy per request: prefix `nav.links[].href` and `activePrefix` with the locale and set `homeUrl` (where the logo links) to the locale's home, for example `/de`.
```tsx title="app/[locale]/docs/layout.tsx"
import type { ReactNode } from "react";
import { resolveLabels } from "pzzadocs";
import { DocsLayout } from "pzzadocs/ui";
import config from "@/docs.config";
import { source } from "@/lib/source";
interface LayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function Layout({ children, params }: LayoutProps) {
const { locale } = await params;
const tree = await source.getPageTree(locale);
return (
{children}
);
}
```
### Page [#page]
Every source call receives the locale, `generateStaticParams` enumerates every page of every locale, and `DocsPage` gets the `locale` for the "Last updated" date format and the `labels` for its footer.
```tsx title="app/[locale]/docs/[[...slug]]/page.tsx"
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { resolveLabels } from "pzzadocs";
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "pzzadocs/ui";
import config from "@/docs.config";
import { locales } from "@/lib/locales";
import { source } from "@/lib/source";
interface PageProps {
params: Promise<{ locale: string; slug?: string[] }>;
}
export async function generateStaticParams() {
const params = await Promise.all(
locales.map(async (locale) => {
const pages = await source.getPages(locale);
return pages.map((page) => ({ locale, slug: page.slug }));
}),
);
return params.flat();
}
export async function generateMetadata({ params }: PageProps): Promise {
const { locale, slug = [] } = await params;
const page = await source.getPage(slug, locale);
if (!page) {
return {};
}
return { title: page.title, description: page.description };
}
export default async function Page({ params }: PageProps) {
const { locale, slug = [] } = await params;
const page = await source.getPage(slug, locale);
if (!page) {
notFound();
}
const [tree, neighbours] = await Promise.all([source.getPageTree(locale), source.getNeighbours(slug, locale)]);
return (
{page.title}{page.description}{page.content}
);
}
```
`page.url` already carries the locale prefix, so `markdownPath(page.url)` and the Markdown route under `app/[locale]` line up without extra work. The Markdown and llms routes take the locale the same way: `source.getMarkdown(slug, locale)`, `source.getLlmsTxt(locale)`, `source.getLlmsFullTxt(locale)`.
### Search index [#search-index]
The search dialog fetches one index, so serve one per locale. The root layout above points `indexUrl` at `/api/search?locale=de`; the route reads the query parameter and builds the index for that locale.
```ts title="app/api/search/route.ts"
import { source } from "@/lib/source";
import { isLocale } from "@/lib/locales";
export async function GET(request: Request) {
const locale = new URL(request.url).searchParams.get("locale") ?? undefined;
const index = await source.getSearchIndex(locale && isLocale(locale) ? locale : undefined);
return Response.json(index);
}
```
A route that reads the request can no longer be `force-static`. To keep static output, write one JSON file per locale at build time with `getSearchIndex(locale)` and set `indexUrl` to `/search/${locale}.json`.
### Default locale at the root [#default-locale-at-the-root]
With `localePrefix: "as-needed"` the default locale's URLs have no segment, so `/docs/guides/setup` has to reach the same page as `/en/docs/guides/setup`. Rewrite root requests to the default locale in `next.config.ts`, or keep a second copy of the routes without the segment. With `"always"`, redirect `/docs` and bare `/` to `/en/docs` instead. With `"never"`, mount the routes once without a `[locale]` segment and choose the locale from whatever your routing library provides.
```ts title="next.config.ts"
import type { NextConfig } from "next";
const config: NextConfig = {
async rewrites() {
return [{ source: "/docs/:path*", destination: "/en/docs/:path*" }];
},
};
export default config;
```
## UI strings [#ui-strings]
Every string the framework renders is a key of `UiLabels`. `translations` maps a locale to a partial set of overrides:
```ts title="docs.config.ts"
export default defineConfig({
defaultLocale: "en",
locales: ["de"],
translations: {
de: {
searchShort: "Suchen...",
searchPlaceholder: "Dokumentation durchsuchen...",
searchNoResults: 'Keine Ergebnisse für "{query}"',
onThisPage: "Auf dieser Seite",
previous: "Zurück",
next: "Weiter",
lastUpdated: "Zuletzt aktualisiert",
copyMarkdown: "Markdown kopieren",
},
},
});
```
`resolveLabels(config, locale)` returns the full `UiLabels` object for a locale: `translations[locale]` merged over `DEFAULT_LABELS`. When `locale` is omitted it uses `defaultLocale`, so you can translate the default locale too. Pass the result to the three components that render framework text:
```ts
import { resolveLabels } from "pzzadocs";
const labels = resolveLabels(config, locale);
```
", description: "Provides the labels to every client component: search trigger and dialog, TOC, breadcrumbs, pagination, page actions, mobile navigation, theme toggle, code copy buttons, image zoom, and the banner." },
"DocsLayout.labels": { type: "Partial", description: "Labels for the server-rendered header, currently the repository icon link." },
"DocsPage.labels": { type: "Partial", description: "Labels for the server-rendered footer, currently the last updated line." },
"DocsPage.locale": { type: "string", default: "\"en-US\"", description: "BCP 47 tag passed to toLocaleDateString for the last updated date. Pass the route locale." },
}}
/>
Client components read labels from `RootProvider`, so omitting `labels` on `DocsLayout` or `DocsPage` only leaves those two server-rendered strings in English. Omitting it everywhere renders the English defaults.
### Placeholders [#placeholders]
Two labels contain a placeholder that the framework fills at render time with `fillLabel`:
| Key | Placeholder | Filled with |
| --- | --- | --- |
| `searchNoResults` | `{query}` | The text typed into the search dialog. |
| `openIn` | `{name}` | The assistant name in the page actions menu, for example "Claude" or "Cursor". |
Keep the placeholder in your translation, in any position the language needs. `fillLabel` is exported for your own strings as well: `fillLabel("Open in {name}", { name: "Cursor" })` returns `Open in Cursor`, and an unknown placeholder is left in place.
### Label reference [#label-reference]
Every key of `UiLabels` with its English default and where the framework renders it.
| Key | Default | Where it appears |
| --- | --- | --- |
| `search` | `Search` | Accessible name of the header search trigger. |
| `searchShort` | `Search...` | Visible text of the header search trigger. |
| `searchPlaceholder` | `Search documentation...` | Placeholder of the search input. |
| `searchDialog` | `Search documentation` | Accessible name of the search dialog. |
| `searchResults` | `Results` | Accessible name of the result list. |
| `searchLoading` | `Loading...` | Shown while the index is being fetched. |
| `searchNoResults` | `No results for "{query}"` | Empty state of the search dialog. |
| `searchError` | `Could not load search index` | Shown when the index request fails. |
| `onThisPage` | `On this page` | Heading of the table of contents, desktop and popover. |
| `previous` | `Previous` | Label of the previous page card in the pagination footer. |
| `next` | `Next` | Label of the next page card in the pagination footer. |
| `pagination` | `Pagination` | Accessible name of the pagination footer. |
| `breadcrumb` | `Breadcrumb` | Accessible name of the breadcrumb trail. |
| `lastUpdated` | `Last updated` | Prefix of the timestamp in the page footer. |
| `copyMarkdown` | `Copy Markdown` | Main button of the page actions menu. |
| `copied` | `Copied` | Button text right after the Markdown was copied. |
| `markdownCopied` | `Markdown copied to clipboard` | Screen reader announcement after copying. |
| `viewOptions` | `View options` | Accessible name of the page actions dropdown trigger. |
| `openInGitHub` | `Open in GitHub` | Dropdown entry, shown when `editUrl` is set. |
| `viewAsMarkdown` | `View as Markdown` | Dropdown entry linking to the Markdown route. |
| `openIn` | `Open in {name}` | Dropdown entries that open the page in an assistant. |
| `copyCode` | `Copy code` | Accessible name of the copy button on code blocks. |
| `openNavigation` | `Open navigation` | Accessible name of the mobile menu button. |
| `closeNavigation` | `Close navigation` | Accessible name of the mobile drawer close button. |
| `navigation` | `Navigation` | Accessible name of the mobile drawer. |
| `switchToLight` | `Switch to light theme` | Accessible name of the theme toggle while dark. |
| `switchToDark` | `Switch to dark theme` | Accessible name of the theme toggle while light. |
| `repository` | `Repository` | Accessible name of the GitHub icon link in the header. |
| `zoomImage` | `Zoom image` | Accessible name of zoomable images. |
| `closeImage` | `Close image` | Accessible name of the lightbox close button. |
| `dismiss` | `Dismiss` | Accessible name of the Banner close button. |
| `selectLanguage` | `Select language` | Accessible name of the locale switcher trigger and menu. |
### Labels in custom components [#labels-in-custom-components]
`useLabels()` from `pzzadocs/ui` returns the resolved `UiLabels` for the current locale inside any client component under `RootProvider`. Outside a provider it returns the English defaults, so a component works in isolation too.
```tsx title="components/search-button.tsx"
"use client";
import { useLabels, useSearch } from "pzzadocs/ui";
export function SearchButton() {
const labels = useLabels();
const { setOpen } = useSearch();
return (
);
}
```
Your own strings do not belong in `UiLabels`; keep them in whatever i18n setup the rest of the app uses. `LabelsProvider` is exported as well, for a subtree that needs a different set of labels than the one `RootProvider` received.
## Locale switcher [#locale-switcher]
`LocaleSwitcher` from `pzzadocs/ui` is a header menu for changing the language, styled like the page actions menu. Each entry is a link when it has an `href`, otherwise a button that calls `onSelect`; use the link form when every locale has a URL you can compute, and the callback form with routing libraries that switch locales programmatically. Put it in the `headerEnd` slot of `DocsLayout`.
```tsx title="components/docs-locale-switcher.tsx"
"use client";
import { LocaleSwitcher } from "pzzadocs/ui";
import { usePathname } from "next/navigation";
const LOCALES = [
{ code: "en", label: "English" },
{ code: "de", label: "Deutsch" },
{ code: "fr", label: "Français" },
];
export function DocsLocaleSwitcher({ current }: { current: string }) {
const pathname = usePathname();
// Strip the current prefix, then add the target one; the default locale has none.
const base = current === "en" ? pathname : pathname.replace(`/${current}`, "");
return (
({ ...locale, href: locale.code === "en" ? base : `/${locale.code}${base}` }))}
/>
);
}
```
void", description: "Called with the chosen code when the entry has no href, or in addition to navigating when it has one." },
className: { type: "string", description: "Added to the wrapper element." },
}}
/>
The trigger shows the active entry's icon and `short` text (the upper-cased code by default). The menu supports arrow keys, Home, End and Escape, and its accessible name comes from the `selectLanguage` label.
## Related [#related]
---
# Sidebar
Root folders, versioned docs, default open levels, prefetching, and icons.
The sidebar is generated from the page tree, which in turn comes from the folder structure and `meta.json` manifests described in [Writing Content](/guides/content). This page covers the behaviours layered on top of that tree.
## Root folders [#root-folders]
A folder with `"root": true` in its `meta.json` becomes a **sidebar root**. When the tree contains roots, the top of the sidebar shows a switcher with one entry per root (icon and title), and only the active root's children are listed below it. Pages that belong to no root are shown when no root is active. The folder's `description` is carried on the tree node for custom switchers but is not rendered by the built-in one.
```json title="content/docs/sdk/meta.json"
{
"title": "SDK",
"description": "Client libraries for every platform.",
"icon": "Package",
"root": true
}
```
```json title="content/docs/cli/meta.json"
{
"title": "CLI",
"description": "Commands and flags.",
"icon": "Terminal",
"root": true
}
```
Use roots to split a large site into products, platforms, or audiences, the same way header tabs do but driven by content instead of config. Header tabs and roots compose: a tab can point at the first page of a root.
## Versioning [#versioning]
Give `root` a string instead of `true` to mark roots of the same **type**. Roots that share a type are interchangeable: when a reader switches from one to another, the sidebar keeps the relative path if the target root has the same page.
```json title="content/docs/v2/meta.json"
{ "title": "v2", "root": "version", "defaultOpen": true }
```
```json title="content/docs/v1/meta.json"
{ "title": "v1", "root": "version" }
```
Reading `/docs/v2/guides/auth` and switching to v1 lands on `/docs/v1/guides/auth` when that file exists, and on the v1 index otherwise. The helpers behind this are exported from the root entry (`getRootFolders`, `getActiveRoot`, `getSidebarNodes`, `projectUrlToRoot`) if you build a custom switcher.
## Open state [#open-state]
Which folders start expanded is decided in this order:
1. The folder containing the current page is always open.
2. A folder with `defaultOpen` in its `meta.json` uses that value.
3. Otherwise, folders at depth `sidebar.defaultOpenLevel` or shallower start open. The default is `0`, so only folders that set `defaultOpen` begin expanded.
```ts title="docs.config.ts"
sidebar: {
defaultOpenLevel: 1,
},
```
Folders with `"collapsible": false` always show their children and render without a chevron.
## Prefetching [#prefetching]
Sidebar links use `next/link`, which prefetches the target route when it enters the viewport. On very large sites that can mean a lot of requests; turn it off with `sidebar: { prefetch: false }`. Navigation still works the same, it just fetches on click.
## Icons [#icons]
Pages set `icon` in frontmatter and folders set `icon` in `meta.json`. Values are names from the bundled icon set in PascalCase (`BookOpen`) or kebab-case (`book-open`). Icons are resolved once on the server, so the tree passed to the client stays serializable. Separators and link items take an icon through the `[Icon]` prefix in `meta.json`:
```json
"pages": ["---[Compass]Navigation---", "[Rocket][Changelog](/changelog)"]
```
This site's sidebar uses all three: folder icons, icon separators in the Components section, and an external link item under API Reference.
## Mobile [#mobile]
Below 768px the sidebar is hidden and opens from the hamburger button in the header as a right-side drawer with the same tree, switcher, and open state.
## Sidebar footer [#sidebar-footer]
`DocsLayout` accepts a `sidebarFooter` node rendered at the bottom of the desktop sidebar, for example a version badge or a link to the changelog:
```tsx title="app/docs/layout.tsx"
v1.0}>
{children}
```
---
# Search
The structured search index, record types, keyboard shortcuts, and the search config.
Search runs entirely in the browser. The dialog fetches a JSON index once, scores records as you type, and groups hits by page. There is no external service to configure.
## Setup [#setup]
Serve the index from a route handler at the URL in `search.indexUrl` (default `/api/search`):
```ts title="app/api/search/route.ts"
import { source } from "@/lib/source";
export const dynamic = "force-static";
export async function GET() {
return Response.json(await source.getSearchIndex());
}
```
`RootProvider` receives the `search` section of the config and renders the dialog plus the keyboard shortcut. The header shows a "Search..." trigger with the shortcut hint.
## The index [#the-index]
`getSearchIndex()` returns a flat `SearchRecord[]`. Every page contributes one `page` record, one `heading` record per heading, and one `text` record per paragraph, list item, table cell, or blockquote.
```ts
interface SearchRecordBase {
id: string;
pageUrl: string;
url: string;
breadcrumbs: string[];
tag?: string;
}
interface SearchPageRecord extends SearchRecordBase {
type: "page";
title: string;
description?: string;
}
interface SearchHeadingRecord extends SearchRecordBase {
type: "heading";
content: string;
pageTitle: string;
}
interface SearchTextRecord extends SearchRecordBase {
type: "text";
content: string;
headingId?: string;
pageTitle: string;
}
type SearchRecord = SearchPageRecord | SearchHeadingRecord | SearchTextRecord;
```
- `url` on heading and text records includes the `#hash`, so selecting a hit scrolls to the right section.
- `breadcrumbs` holds the folder names leading to the page and is shown under each group.
- `tag` comes from the page's frontmatter and is there for custom filtering if you build your own UI on top of the index.
The index is compiled from every page and cached until any content file changes, so the first request after a content edit is the only slow one.
## Scoring and grouping [#scoring-and-grouping]
Matches are scored per record: a hit in a page title counts far more than one in a description, a heading more than body text, and exact phrase matches more than scattered words. Records are then grouped by page, groups are ordered by their best hit, and each group shows its top hits under the page title. Only the best few groups are shown so the list stays scannable.
## Keyboard [#keyboard]
| Key | Action |
| --- | --- |
| `⌘K` / `Ctrl+K` | Open the dialog. The letter is `search.hotKey`. |
| `↑` / `↓` | Move the selection across groups and hits. |
| `Enter` | Navigate to the selected result. |
| `Esc` | Close the dialog. |
| `D` | Toggle light and dark (not a search key, but registered by the same provider; disable with `theme.hotKey: false`). |
Hotkeys are ignored while an input, textarea, or editable element has focus.
## Config [#config]
## Static index [#static-index]
Because the route handler is `force-static`, the index becomes a JSON file in the build output. If you prefer to generate it yourself, write `getSearchIndex()` to `public/search.json` in a build script and set `search: { indexUrl: "/search.json" }`.
## Custom UI [#custom-ui]
`useSearch()` from `pzzadocs/ui` returns `{ enabled, indexUrl, open, setOpen }`, so a custom button anywhere inside `RootProvider` can open the dialog:
```tsx
"use client";
import { useSearch } from "pzzadocs/ui";
export function SearchButton() {
const { setOpen } = useSearch();
return ;
}
```
---
# LLM Outputs
llms.txt, llms-full.txt, per-page Markdown, the page actions menu, and the llm config.
Documentation is read by assistants as often as by people. PzzaDocs produces three plain-text outputs from the same content so agents can index the site without scraping HTML, and surfaces them to readers through a page actions menu.
## Outputs [#outputs]
| Output | Source method | Contents |
| --- | --- | --- |
| `/llms.txt` | `getLlmsTxt()` | An index of every page with its title, URL, and description, organised by the page tree. |
| `/llms-full.txt` | `getLlmsFullTxt()` | Every page's Markdown concatenated in tree order. |
| `.mdx` | `getMarkdown(slug)` | One page as raw Markdown with frontmatter stripped, the title as an H1, and `[#id]` anchors after each heading. |
The anchors in the per-page Markdown match the rendered heading ids, so an assistant can deep-link into the HTML page it was reading in text form.
## Routes [#routes]
Add two route handlers for the site-wide files:
```ts title="app/llms.txt/route.ts"
import { source } from "@/lib/source";
export const dynamic = "force-static";
export async function GET() {
return new Response(await source.getLlmsTxt(), {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
```
```ts title="app/llms-full.txt/route.ts"
import { source } from "@/lib/source";
export const dynamic = "force-static";
export async function GET() {
return new Response(await source.getLlmsFullTxt(), {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
```
Per-page Markdown needs its own segment, because a route handler cannot live next to `page.tsx` inside `app/docs/[[...slug]]`:
```ts title="app/llms.mdx/[[...slug]]/route.ts"
import { source } from "@/lib/source";
export const dynamic = "force-static";
export async function generateStaticParams() {
const pages = await source.getPages();
return pages.map((page) => ({ slug: page.slug }));
}
export async function GET(_request: Request, context: { params: Promise<{ slug?: string[] }> }) {
const { slug = [] } = await context.params;
const markdown = await source.getMarkdown(slug);
if (!markdown) {
return new Response("Not found", { status: 404 });
}
return new Response(markdown, { headers: { "content-type": "text/markdown; charset=utf-8" } });
}
```
### Linking the menu to the route [#linking-the-menu-to-the-route]
Set `siteUrl` in the config (this site uses `https://docs.pzza.works`) so the links in `llms.txt` and `llms-full.txt` and the prompts behind "Open in Claude", "Open in ChatGPT" and "Open in Cursor" are absolute; pass it to `DocsPage` as `siteUrl={config.siteUrl}`. Without it the files use relative paths and the menu falls back to the browser origin.
`llm.markdownPath` maps a page URL to its Markdown URL. The default appends `.mdx` to the page URL (`/docs/guides/llm` becomes `/docs/guides/llm.mdx`, and the index `/docs` becomes `/docs.mdx`). You have two options to make that URL resolve:
Keep the default and add a `proxy.ts` that rewrites `.mdx` requests under `baseUrl` to the handler. This is what this site does, so both `/docs/guides/llm.mdx` and `/llms.mdx/guides/llm` work.
```ts title="proxy.ts"
import { NextResponse, type NextRequest } from "next/server";
const BASE_URL = "/docs";
const MARKDOWN_ROUTE = "/llms.mdx";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.endsWith(".mdx")) {
const page = pathname.slice(0, -".mdx".length);
if (page === BASE_URL || page.startsWith(`${BASE_URL}/`)) {
const url = request.nextUrl.clone();
url.pathname = `${MARKDOWN_ROUTE}${page.slice(BASE_URL.length)}`;
return NextResponse.rewrite(url);
}
}
return NextResponse.next();
}
```
Point `markdownPath` straight at the handler and skip the rewrite:
```ts title="docs.config.ts"
llm: {
markdownPath: (url) => url.replace(/^\/docs/, "/llms.mdx"),
},
```
## Page actions [#page-actions]
Pass the computed Markdown URL to `DocsPage`. When `markdownUrl` or `editUrl` is present, a toolbar appears above the article with a **Copy Markdown** button and a **View options** menu containing Open in GitHub, View as Markdown, and open-in-assistant links that prefill a prompt with the page URL.
```tsx title="app/docs/[[...slug]]/page.tsx"
```
The toolbar is the `PageActions` component from `pzzadocs/ui`; it can also be rendered on its own:
```tsx
import { PageActions } from "pzzadocs/ui";
```
## Config [#config]
string", default: "(url) => `${url}.mdx`", description: "Maps a page URL to the URL serving its Markdown." },
}}
/>
## Try it [#try-it]
- [/llms.txt](/llms.txt)
- [/llms-full.txt](/llms-full.txt)
- [/docs/guides/llm.mdx](/guides/llm.mdx), this page as Markdown
---
# Theming
Restyle PzzaDocs from one config object, from global CSS tokens, or from the stable class names.
PzzaDocs never hardcodes a colour, size or font. Every value in the stylesheet comes from a `--pd-*` custom property, and the whole look is a thin layer of those tokens on top of a monochrome base. You can change that layer at three levels, from the least to the most involved:
1. **The `theme` section of `docs.config.ts`.** Pick a preset, override colours, set the radius, fonts, layout widths, density and the code theme. Everything stays in one typed object and is emitted as CSS at render time.
2. **Token overrides in `app/globals.css`.** Redefine any `--pd-*` property on `:root` and `.dark`. Useful when the values already live in your own design system.
3. **Class-based CSS.** Every element the framework renders carries a stable `pd-*` class and state attributes, so you can restyle a single piece of chrome without touching the rest.
Most sites only need the first level. The other two are covered in [CSS overrides](/guides/css-overrides).
## Quick example [#quick-example]
```ts title="docs.config.ts"
import { defineConfig } from "pzzadocs";
export default defineConfig({
title: "Acme Docs",
theme: {
preset: "warm",
radius: 6,
density: "compact",
fonts: {
sans: "var(--font-geist-sans), system-ui, sans-serif",
mono: "var(--font-geist-mono), ui-monospace, monospace",
},
},
});
```
This switches the palette to the warm paper-and-ink preset, rounds every control to 6px, tightens the row heights and paddings, and points the two font tokens at variables published by `next/font`. Nothing else changes.
## How it is applied [#how-it-is-applied]
`RootProvider` renders a server component called `ThemeStyles`. It reads the resolved `theme` config and emits a single `