Skip to content

Wave Docs

Markdown documentation for Next.js. The parser runs in Node at build time and never reaches the browser.

Point @waveso/docs at a folder of .md files and you get a documentation site: routing, navigation, a table of contents, syntax highlighting, search, redirects and a sitemap.

This site is one of them. Every page in the sidebar is a markdown file in site/content/, rendered by the package you would install, from a layout file that is one line of shell and no CSS of its own.

The decision everything follows from

Markdown becomes hast (opens in a new tab) in Node, at build time. A hast tree is plain serialisable JSON, so Next renders it inside a Server Component and the browser receives a tree of nodes and a component map — never unified, never remark-parse, never Shiki.

content/*.md ──▶ source ──▶ render ──▶ { hast, toc, frontmatter }
                 (Node)     (Node)              │
                                          RSC payload
                                                │
                                        <DocContent hast={…} />

Three things follow from that shape, and they are the reasons to choose this over the alternatives.

Nothing is stringified to HTML. The pipeline stops at hast, so the output stays data. You map h2, a, img, pre and callout onto your own components, and nothing is ever handed to dangerouslySetInnerHTML.

Table-of-contents anchors cannot drift. Heading ids are read off the same pass that annotated the document, rather than recomputed by a second parse. Two sections called "Install" get #install and #install-1, and the rail on the right matches — by construction, not by coincidence.

Broken internal links fail the build. [auth](./api/auth.md) is the right way to link between markdown files: it resolves on GitHub and in every editor preview, and it 404s once published. Those links are rewritten to routes, their targets checked, and their #fragments checked against the headings that exist.

What a reader downloads

At most
Everything the quick start ships, gzipped14.9 KB
Without the search dialogunder 4 KB
Markdown parsernone
Syntax highlighternone

Every figure is a ceiling rather than a measurement somebody remembered to update: pnpm size fails the build when one is passed, in CI and again in prepublishOnly, and fails it again if the published table ever promises better than the code delivers.

The honest cost is on the other side of the ledger. Shipping a tree instead of a string is about 20% more brotli on a prose page, and about 12% on a page with code and tables. That is the price of never handing markup to dangerouslySetInnerHTML, and it is the first number a sceptical reviewer should ask for.

One command, six files

npx @waveso/docs init

That writes all six into an existing Next application, and never overwrites what is already there. They are small enough to read:

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

export const docs = createDocsRoute({ contentDir: 'content/docs' });
app/docs/layout.tsx
import '@waveso/docs/styles.css';
import { docs } from '@/lib/docs';

export default docs.Layout;

The other four are a catch-all page, an index page, the search-index route and the corpus route — each one a re-export, each one explained in the quick start. What that gets you is a working documentation site: routing, a navigation sidebar, a table of contents, syntax highlighting, search, a mobile drawer, a skip link, a corpus for agents and a button that copies any page as markdown.

A scaffold for six small files sounds like overkill until you notice that three of them fail silently when they are slightly wrong — see the quick start for which three, and what each one costs.

Three peer dependencies, one of them optional. No Tailwind, no MDX toolchain, no build step of your own.

Enforced, not intended

Documentation is full of promises. These are the ones with a test behind them, and the test is named so it can be read.

Enforced by
Every published cost figure is a ceilingpnpm size
Every subpath, and every name it exports, is public APImanifest.test.ts
Every DocsErrorCode member is public APIerror-taxonomy.test.ts
Which Node builtins each entry point needs, exactlyentry-runtime.test.ts
Every foreground and background pair clears WCAG 4.5:1styles.test.ts
Every code example in the README compilespnpm run check:readme
The shell needs no layout CSS from its hostsite-budget.test.ts

That last one is what this site is for. A documentation site built by the people who wrote the package will work — they know which class to add when a column collapses, and they add it locally without noticing they have papered over a defect every consumer will hit. So this site is held to a hard zero: no stylesheet, no inline layout style, no wrapper between the shell and the page. Anything it needs and cannot have is a bug report against the package, filed by construction rather than by goodwill.

Where to go next