Skip to content

Configuration

createDocsRoute takes one object and returns one. The object it takes is DocsRouteOptions: the seven fields of DocsConfig, which describe the content tree and are shared with every other function in the package, plus thirteen that configure the render pipeline, search, the component map and the chrome.

The type

interface DocsRouteOptions<
  TFrontmatter extends DocFrontmatter = DocFrontmatter,
> {
  contentDir: string;
  basePath?: string;
  includeDrafts?: boolean;
  siteUrl?: string;
  frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter>;
  onBrokenLinks?: DocsLinkSeverity;
  onBrokenAnchors?: DocsLinkSeverity;
  externalRoutes?: readonly string[];
  langs?: readonly DocsLang[];
  themes?: DocsThemes;
  highlighter?: DocsHighlighter | Promise<DocsHighlighter>;
  excludeLangs?: readonly string[];
  titleHeading?: boolean;
  remarkPlugins?: PluggableList;
  rehypePlugins?: PluggableList;
  linkResolver?: LinkResolver;
  imageResolver?: ImageResolver;
  miniSearchOptions?: Partial<Options<SearchRecord>>;
  components?: MarkdownComponents;
  labels?: DocsLabels;
  llms?: DocsLlmsOptions;
  copyPage?: boolean | DocsCopyPageConfig;
}

type DocsLinkSeverity = 'throw' | 'warn' | 'ignore';

Twenty-two options, one of them required. Every optional is really declared ?: T | undefined rather than ?: T, so a value you computed and may hand over as undefined is accepted under exactOptionalPropertyTypes instead of forcing a conditional spread at the call site.

Content and routing

OptionTypeDefault
contentDirstring—The content root. Relative paths resolve against process.cwd()
basePathstring'/docs'URL prefix the routes are mounted at. Multiple segments are fine
includeDraftsbooleanfalsePublish pages marked draft: true. Deliberately not tied to NODE_ENV
siteUrlstring—Absolute origin. Makes every alternates.canonical and og:url absolute

Drafts are excluded from navigation, search and generateStaticParams, and a link to one fails the build with draft-link rather than the generic broken-link — the message names the reason, because "no such page exists" is unfollowable advice about a file plainly sitting on disk. Gate includeDrafts on an env check of your own: a Vercel preview is a production build, so branching on NODE_ENV hides drafts in the one place reviewers look. See Writing content.

contentDir

Resolved with path.resolve(process.cwd(), contentDir), which is the project root under both next build and vite build. The resolved path is the first fragment of the memoisation key: sources are cached per resolved config, so the route files of a docs site share one filesystem scan rather than each starting their own. Calling createDocsRoute once at module scope in every route file is the intended shape, and the second call is free.

basePath

Normalised to a leading slash and no trailing one, so every href elsewhere is a plain concatenation. '/' normalises to '' — the Next convention — and runs of slashes collapse, which is not cosmetic: '//docs' is read by a browser as scheme-relative, and every canonical, og:url and sitemap entry on the site would point at a host called docs.

A root mount changes one behaviour, and only one: absolute links carry no prefix to test against, so externalRoutes starts to matter.

siteUrl

A bare origin, or the build fails with invalid-config. Canonicals and sitemap entries are built with new URL(href, siteUrl), and that discards a path: '/docs/x' against 'https://example.com/product-docs' yields https://example.com/docs/x. The whole site would then publish canonicals pointing at URLs that 404, and Google reads a canonical aimed at a 404 as a reason to drop the page. Put the path in basePath, which does take multiple segments.

Omit it and the canonical is a root-relative path, which Next resolves against metadataBase. Set neither and the pages ship with no usable canonical at all.

Markdown for agents

llms

llms?: {
  title: string;
  description?: string;
  details?: string;
}

Turns on docs.llmsTxt and docs.llmsFullTxt, and with them the Copy page button. title is the h1 of llms.txt — your product's name, which is why it is required rather than defaulted: an index calling your product "Documentation" is worse than no index.

Links inside both files resolve against siteUrl rather than a second copy of it. Without one they stay root-relative, which is the honest half of the feature for docs mounted inside a private application.

copyPage

copyPage?: boolean | { label?: string; copiedLabel?: string; failedLabel?: string }

A button at the top of every page that puts that page's markdown on the clipboard. The default is "on if llms is set", not plain true — the button reads the corpus, and docs.llmsFullTxt refuses to serve one without llms, so a site without it has nothing for the button to read.

false turns it off, which is worth doing for a /docs embedded in an application that would rather not spend the kilobyte. An explicit true overrides the default, for a host serving the corpus some other way.

Any page overrides it in frontmatter — copyPage: false on a landing page that is a hero and a row of cards, true to turn it back on where the route turned it off.

Markdown for agents has the rest, including what the button costs and why it does not use a .md URL per page.

Validation

OptionTypeDefault
frontmatterSchemaStandardSchemaV1<unknown, TFrontmatter>docFrontmatterSchemaValidates every page's frontmatter, and types what you read back
onBrokenLinksDocsLinkSeverity'throw'An internal link that resolves to no published page
onBrokenAnchorsDocsLinkSeverity'throw'A #fragment no heading on the target page owns
externalRoutesreadonly string[][]Route prefixes your application owns, not the documentation

frontmatterSchema

Any Standard Schema (opens in a new tab) validator — Zod, Valibot, ArkType — rather than a Zod type specifically, so the package dictates no validator and a schema handed over through the spec interface cannot hit the cross-instance mismatch two copies of Zod in one node_modules produce. The inferred TFrontmatter flows into every DocFile and RenderedDoc the route returns, with no type argument anywhere in the route file.

The package's own six fields — title, description, label, draft, aliases, order — are re-read from the YAML and laid back over your output whatever your schema says, so a bare z.object({ title, audience }) cannot lose a redirect. It only costs you the six in the inferred type.

The two severities

Both take 'throw', 'warn' or 'ignore', and both default to 'throw'. The argument for leaving them there, what each of the two checks covers and the messages the errors carry are in Links.

externalRoutes

Only meaningful at a root mount, because under basePath: '/docs' an absolute link either carries the prefix — so it is documentation — or it is not documentation at all. Links has the rest.

The pipeline

OptionTypeDefault
langsreadonly DocsLang[]eighteen grammarsGrammars loaded into the default highlighter. Anything else falls back to plain text
themesDocsThemesgithub-light / github-darkThe light and dark pair. Both are emitted, and CSS picks
highlighterDocsHighlighter | Promise<DocsHighlighter>built from langs and themesReuse your own — the escape hatch for a grammar or theme outside the curated set
excludeLangsreadonly string[]—Fence languages Shiki must not touch, e.g. ['mermaid']
titleHeadingbooleantruePrepend an <h1> from frontmatter.title when the body has none
remarkPluginsPluggableList—Attached after remarkGfm, before link resolution
rehypePluginsPluggableList—Attached after heading ids and permalinks, before the code steps
linkResolverLinkResolverbuilt-inReplaces markdown-link resolution entirely
imageResolverImageResolver—Maps an image src to a public URL and its intrinsic dimensions

Turn titleHeading off only if your layout renders the page title itself: a document with no h1 fails page-has-heading-one, and markdown that repeats the frontmatter title as # is a duplication authors forget to keep in step.

Both themes are emitted at once, and CSS picks. Shiki is run with defaultColor: false, so a <pre> carries --shiki-light and --shiki-dark custom properties rather than an inline background-color from whichever theme was called default — which used to paint the light background onto code blocks in dark mode, and could only be fought off with !important. See Theming.

The two plugin positions are the useful ones and are not negotiable — Plugins explains what each slot sees, and what one processor shared by every file means for a plugin that holds state. Grammars and fence handling are in Code blocks; the resolvers, their arguments and the folding done before they are called are in Images and Links.

OptionTypeDefault
miniSearchOptionsPartial<Options<SearchRecord>>{}MiniSearch overrides for the index docs.searchIndex builds

The identical object must reach the dialog. MiniSearch reads tokenize and processTerm both when indexing and when querying, so applying one here and not there produces an index whose terms no query can spell: zero results, no error, nothing in the console. docs.Layout forwards the serialisable half for you and throws invalid-config on the rest rather than dropping it quietly. Search has the 'use client' recipe for a function-valued override.

Components

OptionTypeDefault
componentsMarkdownComponentsnext/link + next/imageOverrides, merged over the Next-flavoured defaults

The default map is built once per process and wires next/link and next/image through dynamic imports, so next.config.ts can load this entry point for the sitemap and the redirects without either on its module graph. Yours are merged over it key by key. The element names available are in Components.

The chrome

OptionTypeDefault
labelsDocsLabelsEnglishEvery string this package renders that is not your content

The route is where they belong, because they do not all live in one runtime. Four are rendered by the shell, three by the navigation tree, two by the table of contents, nine by the component map, two by a client-side copy runtime, and two are baked into the HTML by a rehype plugin at build time — so docs.Layout's own labels prop can reach four of the twenty-two, and overrides these key by key rather than replacing them. Translating the chrome lists all twenty-two with their defaults.

What the route returns

MemberType
Page(props: DocsPageProps) => Promise<ReactNode>Default export for app/<basePath>/[...slug]/page.tsx
IndexPage() => Promise<ReactNode>Default export for app/<basePath>/page.tsx. Not optional — [...slug] does not match the base path itself
generateStaticParams() => Promise<Array<{ slug: string[] }>>For the catch-all. The root index.md is absent by design: its segments are []
generateMetadata(props: DocsPageProps) => Promise<DocsPageMetadata>Title, description, alternates.canonical, and an article Open Graph block
dynamicParamsfalseRead it, do not re-export it. Your route file must declare the literal
Layout(props: DocsLayoutProps) => Promise<ReactNode>The whole shell: skip link, sidebar column, search trigger, mobile drawer, grid
sourceDocsSource<TFrontmatter>all, drafts, find, nav, slugs, invalidate, and the resolved config
getPage(segments: string[]) => Promise<RenderedDoc<TFrontmatter> | undefined>One page rendered — hast, toc, frontmatter. undefined when there is no such page
renderAll() => Promise<Array<RenderedDoc<TFrontmatter>>>Every published page. The escape hatch behind searchIndex
searchIndex() => Promise<Response>GET for app/<basePath>/search-index.json/route.ts
searchIndexUrlstringThe base path with /search-index.json appended. Not prefixed with Next's own basePath config
llmsTxt() => Promise<Response>GET for app/<basePath>/llms.txt/route.ts. Optional — the corpus below carries every page and its URL
llmsFullTxt() => Promise<Response>GET for app/<basePath>/llms-full.txt/route.ts. Every page's markdown, and what the Copy page button reads

Two route files need a route segment config that is a literal, and neither can be forwarded from here. Next parses those out of the module before any of it runs, so export const dynamicParams = docs.dynamicParams fails next build outright — docs.dynamicParams exists to document the value and type it as false, and you write export const dynamicParams = false yourself. The other is dynamic = 'force-static' on the search-index route, which is not on this object at all — Search has what omitting it costs.

Outside a production build the route re-reads the content directory once per request, because markdown is not in Next's module graph and nothing re-evaluates a route module when a file changes.

The other four functions

Each takes a slice of the same config, so one options object can feed all of them.

FunctionConfig it takes
createDocsSourceDocsConfigThe filesystem walk alone — no renderer, no highlighter. Memoised per resolved config
createDocsRendererDocsRendererOptionsIts config is four resolved fields: basePath, onBrokenLinks, onBrokenAnchors, externalRoutes
createDocsSitemapDocsConfig + foursiteUrl is required here, plus changeFrequency, priority and lastModified
createDocsRedirectsDocsConfigEvery published page's aliases, as permanent redirects resolved against basePath

DocsRendererConfig is declared as a Pick of ResolvedDocsConfig so a full resolved config passes straight through — the host resolves configuration once, with resolveDocsConfig, and hands the same object to the walk and to the renderer, which is what keeps the two from drifting. The renderer's own options repeat the pipeline set above and add four the route fills in for you: knownRoutes, draftRoutes and aliasRoutes from the source walk, and codeLabels, which the route derives from labels.

createDocsSitemap's lastModified defaults to the file's mtime, which on a CI runner is the checkout time — every page then claims to have changed today. Wire it to your git history if the dates are load-bearing. Drafts are excluded, and so are aliases: an alias is a redirect, and listing a redirect in a sitemap is a crawl error. Both functions are safe to call from next.config.ts, which loads this entry point outside the Next runtime. Where each one lives is Entry points; what a failure means is Errors.