Skip to content

Quick start

npx @waveso/docs init

That writes every file below into an existing Next application. It never overwrites: anything already there is reported and left alone, so a second run is safe and a project that already has a layout.tsx cannot lose it.

--app-dir <dir>       where the App Router lives            (app)
--config <file>       where createDocsRoute goes            (lib/docs.ts)
--content-dir <dir>   where your markdown lives             (content/docs)
--base-path <path>    where the docs are mounted            (/docs)
--site-url <url>      your origin, for canonicals and links
--llms-index          also write llms.txt, the agent index

Then put markdown in content/docs/ and uncomment siteUrl. Installation is the single dependency all of this assumes.

Six small files, so why a scaffold? Because three of them fail silently when they are slightly wrong. A dynamicParams that is not a literal false builds green and then renders unlisted URLs on demand. A route handler missing export const dynamic = 'force-static' re-renders your whole corpus per request, from markdown that is not in the deployment bundle. An index page nobody created leaves the mount itself a 404. The file count is Next's floor — a route is a folder in your app/, and no package can add one — but the typing is not.

The rest of this page is what it writes, and why.

Create the route once

lib/docs.ts
import { createDocsRoute } from '@waveso/docs/next';

export const docs = createDocsRoute({ contentDir: 'content/docs' });

contentDir resolves against process.cwd(), which is the project root during next build. basePath defaults to /docs, so that is where the routes below are mounted; every other option is in Configuration.

Calling createDocsRoute a second time costs nothing — the filesystem scan, the highlighter and the component map are shared per process. The shared module is for the options, so a siteUrl or an imageResolver is written in one place rather than in each route file.

Two page files, not one

app/docs/[...slug]/page.tsx
import { docs } from '@/lib/docs';

export default docs.Page;
export const generateStaticParams = docs.generateStaticParams;
export const generateMetadata = docs.generateMetadata;
export const dynamicParams = false;
app/docs/page.tsx
import { docs } from '@/lib/docs';

export default docs.IndexPage;
export const generateMetadata = docs.generateMetadata;

The second file is not optional, and leaving it out is the most common way to ship this broken. [...slug] does not match /docs itself: the route table emits /docs/index, and /docs returns 404. The root index.md is absent from generateStaticParams for the same reason — its segments are [], and a catch-all cannot render an empty parameter list.

The fix is a sibling page.tsx, not an optional catch-all. [[...slug]] would match /docs and save this file, at the cost of a duplicate route — Internals has the argument.

Declaring it is not optional either. Next defaults dynamicParams to true, so a URL generateStaticParams never listed is still invoked on demand: the route runs on a server at request time to produce a 404 that was already knowable at build time. output: 'export' refuses to build without it at all.

The layout

app/docs/layout.tsx
import '@waveso/docs/styles.css';
import { docs } from '@/lib/docs';

export default docs.Layout;

That line is the whole shell — a skip link, the grid, and the sidebar that carries the navigation, the search trigger and, below 64rem, the drawer trigger. It renders no header. A host's own header, theme toggle and repository link are rendered around docs.Layout, in the layout file that wraps it. Layout covers the three props and composing your own instead; the stylesheet's tokens are in Theming.

It does not own the table of contents. A Next layout receives { children, params } and cannot know which page is rendering, so docs.Page emits the table of contents as its second child and the grid places it.

The search route

app/docs/search-index.json/route.ts
import { docs } from '@/lib/docs';

export const GET = docs.searchIndex;
export const dynamic = 'force-static';

docs.Layout renders the search trigger by default, so leaving this file out ships a control that opens onto "Search is unavailable right now. Try reloading the page." For a site that genuinely wants no search, search={false} drops both the trigger and this file:

app/docs/layout.tsx
import type { ReactNode } from 'react';
import '@waveso/docs/styles.css';
import { docs } from '@/lib/docs';

export default function DocsLayout({ children }: { children: ReactNode }) {
  return <docs.Layout search={false}>{children}</docs.Layout>;
}

dynamic = 'force-static' must be written out as a literal too, or the route throws search-index-dynamic at the reader, inside the search dialog. Search has that failure in full, and the caching headers the route sets in place of Next's.

The corpus route

app/docs/llms-full.txt/route.ts
import { docs } from '@/lib/docs';

export const GET = docs.llmsFullTxt;
export const dynamic = 'force-static';

Every published page's markdown in one file — for agents, and for the Copy page button, which reads it and slices out the page it is on. The button is on by default once llms is configured, and needs nothing else.

Leave this file out and the button removes itself on the first click; that failure is deliberate, and Markdown for agents has the whole story, including why the button does not use a .md URL per page.

The content folder

content/docs/
  index.md
  getting-started.md
  api/
    meta.json
    authentication.md

index.md at the root is what docs.IndexPage renders; every other file is a page under the catch-all. A directory becomes a section, and meta.json names and orders it — see Navigation. Frontmatter, drafts and schema fields of your own are in Writing content.

What you have now

Routing, a navigation sidebar, a table of contents, syntax highlighting, search, a mobile drawer, a skip link, a corpus for agents and a Copy page button — from six files, none longer than a dozen lines, and no layout CSS of your own. This site is built from the same six.

Every failure this package raises carries a DocsErrorCode, and Errors lists each one with its fix.