Build a docs site from scratch
Assemble the full docs shell - navbar, tab bar, sidebar, command palette, breadcrumb, prev/next, and a ToC minimap - from one Docs instance and a folder of MDX.

By the end of this tutorial you have a docs site at /docs/ with the whole shell running: a brand
navbar, a tab bar, a grouped sidebar, a ⌘K search palette, a breadcrumb, prev/next footer links,
and a table-of-contents minimap. All of it is built from one Docs instance and a folder of MDX.
Quickstart is the minimal version of this: one page, one route pair, no chrome at all. This tutorial is its fuller sibling - same instance, the complete shell around it.
Prerequisites. A Next.js App Router app on Next 15 or newer (the route file in step 5 uses
the awaited params shape) on Node 20.9 or newer, and
@daanvandenbergh/scribekit plus next-mdx-remote installed - see
Installation. This tutorial uses a single language; every path is relative to
your Next app root (the directory next dev runs from). If you have not imported
@daanvandenbergh/scribekit/styles.css in your root layout yet, do that first - the components
ship unstyled without it.
1. Create the Docs instance
One Docs instance reads your content directory at build time and hands every route its data: the
nav tree, the breadcrumb, prev/next, the metadata, the JSON-LD.
Create app/docs/_docs.ts:
import { Docs } from "@daanvandenbergh/scribekit";
/** The single configured Docs instance. Every docs route reads from this. */
export const docs = new Docs({
contentDir: "./docs",
siteUrl: "https://example.com",
brandName: "Example",
description: "Documentation for Example.",
tabs: ["Documentation"],
groups: ["Get started"],
});
contentDir resolves against process.cwd() - the directory next dev or next build runs from -
so "./docs" is a docs/ folder next to your package.json, not inside app/. siteUrl and
brandName must both be set or the SEO methods throw. tabs and groups are display order only -
you extend them in step 8.
trailingSlash is unset, so it defaults to true: every URL the instance builds ends in a slash.
Next's own trailingSlash defaults to false, so the two disagree until you say otherwise. Set it
in next.config.mjs:
export default { trailingSlash: true };
Skip it and nothing breaks in dev - Next just redirects - but the canonical, hreflang, and sitemap
URLs point at a form your build never emits, which on a static export is a sitemap of 404s. Prefer
bare paths? Pass trailingSlash: false to new Docs({...}) and leave next.config alone. Either
way the two have to agree.
2. Build the chrome
DocsTabs and DocsSidebar need the current pathname to highlight the active page, and
usePathname() is a client hook. All four chrome components are client components anyway - the
navbar measures itself, the tab bar measures its active tab - so the chrome is one thin client
component that reads the pathname once and passes it down.
Create app/docs/_docs-chrome.tsx:
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { DocsNavbar, DocsSearchProvider, DocsSidebar, DocsTabs } from "@daanvandenbergh/scribekit/react";
import type { NavTree } from "@daanvandenbergh/scribekit";
/** The full docs shell: navbar and tab bar on top, sidebar beside the routed content. */
export function DocsChrome({ nav, children }: { nav: NavTree; children: ReactNode }) {
const activePath = usePathname();
return (
<DocsSearchProvider nav={nav} linkComponent={Link}>
<div className="scribekit-docs">
<DocsNavbar brandName="Example" homeHref="/docs" linkComponent={Link} />
<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>
);
}
homeHref defaults to "/", which is your app root rather than the docs index - so on a site
mounted at /docs, set it explicitly as above or clicking the brand leaves the docs.
DocsNavbar must sit inside DocsSearchProvider. The provider owns the one ⌘K palette that
both the navbar's search button and the keyboard shortcut open, and the drawer state behind the
mobile hamburger. Put the navbar outside it and nothing errors - the search button still renders and
does nothing when clicked, because outside a provider the open callback is a no-op, and the navbar
drops its hamburger entirely, so below the layout breakpoint the navigation has no opener at all.
That silence is the whole reason to get the nesting right the first time.
3. Wire the layout
The chrome belongs in the layout, not the page, so it survives client-side navigation: the open tab, the sidebar scroll position, and the palette all persist between pages.
Create app/docs/layout.tsx:
import type { ReactNode } from "react";
import { docs } from "./_docs";
import { DocsChrome } from "./_docs-chrome";
/** Docs shell layout, shared by the index and every page. */
export default function DocsLayout({ children }: { children: ReactNode }) {
return <DocsChrome nav={docs.getNavTree()}>{children}</DocsChrome>;
}
getNavTree() runs on the server and returns a plain serialisable tree, which is what lets it
cross into the client chrome as a prop.
4. Add the index route
DocsIndex renders three sections off the same nav tree: a hero with a fact row (article count,
topic count, newest update), a filterable "Browse by topic" grid with one card per sidebar group
listing its first three pages, and a "Recently updated" list. Each is exported separately -
DocsHero, DocsTopicGrid, DocsRecentlyUpdated - if you ever want to compose the index by hand.
Create app/docs/page.tsx:
import { DocsIndex } from "@daanvandenbergh/scribekit/react";
import { docs } from "./_docs";
/** SEO metadata for the docs index. */
export function generateMetadata() {
return docs.indexMetadata();
}
/** The docs landing page at /docs. */
export default function DocsIndexPage() {
return <DocsIndex docs={docs} />;
}
5. Add the page route
Create app/docs/[slug]/page.tsx:
import { DocsPage } from "@daanvandenbergh/scribekit/react";
import { DocNotFoundError } from "@daanvandenbergh/scribekit";
import { notFound, permanentRedirect } from "next/navigation";
import { docs } from "../_docs";
/** Unknown slugs 404 at the router instead of rendering on demand. */
export const dynamicParams = false;
/** Prerender one page per doc, plus every renamed slug from the `redirects` config. */
export function generateStaticParams() {
return [...docs.getDocRefs(), ...docs.getRedirectRefs()].map((ref) => ({ slug: ref.slug }));
}
/** Per-page SEO metadata. Falls back to a "not found" title. */
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" };
}
}
/** A docs page at /docs/<slug>, or a redirect when the slug was renamed. */
export default async function DocSlugPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
try {
docs.getDoc(slug);
} catch (err) {
if (err instanceof DocNotFoundError) {
const renamedTo = docs.getRedirect(slug);
if (renamedTo) permanentRedirect(renamedTo);
notFound();
}
throw err;
}
return <DocsPage docs={docs} slug={slug} />;
}
params is a Promise and must be awaited - the App Router's shape from Next 15 on.
The redirect handling comes as a pair, and half of it is a build failure. Spreading both
getDocRefs() and getRedirectRefs() prerenders the old slug: dynamicParams = false makes an
unrendered slug 404 at the router, before any component could redirect it. But a prerendered
slug with no file on disk makes getDoc throw DocNotFoundError during the build - so the
component has to catch it and hand off to getRedirect. Ship the spread without the catch and the
build breaks the first time you actually rename a page.
Both methods return { slug, lang } entries, and on a single-language site getRedirectRefs()
fills lang with your default locale, so mapping to { slug: ref.slug } is all a [slug] route
needs.
6. Write two pages and watch the sidebar assemble
The folder name is the slug. The file name is the language.
Create docs/getting-started/en.mdx:
---
title: "Getting started"
description: "What this project is and how the pieces fit."
tab: "Documentation"
group: "Get started"
order: 1
icon: "book"
---
The body starts at `##` - the title above renders the H1.
## What this is
Enough prose here that the minimap has a heading to track.
Then docs/installation/en.mdx:
---
title: "Installation"
description: "Install the package and its peer dependencies."
tab: "Documentation"
group: "Get started"
order: 2
icon: "rocket"
---
## Install the package
The second page in the group, so prev/next now has somewhere to point.
order must be a bare number. order: "2" is silently dropped: the page still renders, but it
falls into the unordered bucket and sorts after every ordered sibling. Nothing warns you. When a
page mysteriously sits last in its group, this is why. Within a group, keep order dense - 1,
2, 3 - with no ties, because two pages at the same order tie-break alphabetically by title
rather than by anything you chose.
Start your dev server and open http://localhost:3000/docs/.
The index shows a hero with a "2 articles · 1 topic" fact row and a "Get started" card listing both
pages. Click through to /docs/getting-started/ and the shell is there: the navbar with your brand,
a "Docs" pill beside it, and a search button; the sidebar with a "Get started" group and both pages
under it, "Getting started" highlighted; a breadcrumb reading "Documentation / Get started /
Getting started"; a reading-time pill; a "Next → Installation" link at the foot; a "Was this page
helpful?" widget; and an "On this page" minimap tracking your ## headings as you scroll. Press ⌘K
(Ctrl+K on Windows and Linux) and the palette opens; type install and Installation comes up.
The breadcrumb leads with the tab because these pages set tab: "Documentation" - a page that sets
no tab falls into the implicit empty bucket, which contributes no segment. To drop the extras,
showFeedback={false} removes the widget and docsText={null} removes the "Docs" pill.
One thing is missing: there is no tab bar. That is next.
7. Add a second tab and the tab bar appears
DocsTabs renders null until there is more than one tab. A single-tab corpus has nothing to
switch between, so the component draws nothing - your DocsTabs in step 2 has been returning
null this whole time. It is working correctly.
Give it a second tab. Create docs/deploy-guide/en.mdx:
---
title: "Deploy your site"
description: "Ship the docs to a static host."
tab: "Guides"
group: "Hosting"
order: 1
icon: "globe"
---
## Build the site
A page in a second tab, which is what makes the tab bar render.
Reload /docs/getting-started/. A tab bar now sits under the navbar with "Documentation" and
"Guides", and the sidebar shows only the active tab's groups. Click "Guides" and the sidebar swaps
to "Hosting".
8. Control the section order
Tabs and groups sort by their position in the instance config first, and anything unlisted sorts
after. Your Guides tab and Hosting group are not listed yet, so they landed at the end by
accident rather than by design.
Update app/docs/_docs.ts:
export const docs = new Docs({
contentDir: "./docs",
siteUrl: "https://example.com",
brandName: "Example",
description: "Documentation for Example.",
tabs: ["Documentation", "Guides"],
groups: ["Get started", "Hosting"],
});
Reorder those arrays and the tab bar and sidebar reorder with them. To relabel a section without touching a single page, pass an object instead of a string:
tabs: ["Documentation", { id: "Guides", label: "How-to guides" }],
The id still matches the tab: string in your front-matter; only the displayed label changes.
What you have now
Three pages, two tabs, and the full shell: navbar, tab bar, grouped sidebar with icons, ⌘K
palette, breadcrumb, prev/next, and the ToC minimap. Every new page is one more folder under
docs/ with the right tab, group, and order; no route file changes again.
Every component prop and default used here is listed in the API reference, and the front-matter contract in full is in the docs guide.