Writing content
A page is a .md file with a YAML block at the top. The package reads six
fields from that block. Everything else in it is yours, once you say what it is.
Frontmatter
---
title: Authentication # required
description: Bearer tokens. # <meta name="description"> and search
label: Auth # sidebar label, when the title is too long
draft: true # excluded from nav, search and static params
order: 10 # sort weight where there is no meta.json
aliases: [old-auth, legacy/auth] # former URLs → permanent redirects
---title is required on every .md file in the tree. A file without one fails
the build rather than shipping an untitled page: the title drives the <h1>
fallback, <title>, the sidebar and search, so there is no reader-facing
surface a missing one leaves intact.
description becomes the <meta name="description"> and the search snippet.
It is deliberately not length-capped. The 155–160 character advice is a
pixel-width heuristic about where Google truncates a snippet, not a limit —
enforcing it here would fail a build over prose that renders fine.
label replaces the title in the navigation, and nowhere else. Sidebars are
narrow; page titles are not.
order is a sort weight within one directory, for directories with no
meta.json. Lower sorts first; pages without an order sort last,
alphabetically. Ordering a whole section is
meta.json's job.
aliases are the page's former URLs, relative to the docs base path. The Next
adapter turns them into permanent redirects, so a rename never becomes a silent
404 — Links covers what a legal alias looks like, and why
the spelling is validated rather than trusted.
Drafts
A draft: true page is excluded from the navigation, from the search index and
from generateStaticParams, in every environment. There is no preview mode that
leaks it, and a link to one fails the build as draft-link rather than shipping
a link to a 404.
includeDrafts builds them anyway. It defaults to false.
import { createDocsRoute } from '@waveso/docs/next';
export const docs = createDocsRoute({
contentDir: 'content/docs',
includeDrafts: process.env.DOCS_DRAFTS === '1',
});draft is deliberately not tied to NODE_ENV. Preview deployments are
production builds, so branching on it would hide drafts in exactly the place
reviewers look. Drive includeDrafts from your own environment check instead.
Your own fields
Pass a frontmatterSchema and every DocFile and RenderedDoc carries your
fields, inferred, with no type argument anywhere:
import { docFrontmatterSchema, z } from '@waveso/docs/frontmatter';
export const frontmatterSchema = docFrontmatterSchema.extend({
audience: z.enum(['user', 'operator']).exactOptional(),
});import { createDocsRoute } from '@waveso/docs/next';
import { frontmatterSchema } from '@/content/docs-schema';
export const docs = createDocsRoute({
contentDir: 'content/docs',
frontmatterSchema,
});const doc = await docs.getPage(['api', 'auth']);
doc?.frontmatter.audience; // 'user' | 'operator' | undefined
doc?.frontmatter.title; // stringAny Standard Schema (opens in a new tab) validator works — Zod,
Valibot, ArkType. The field is typed StandardSchemaV1<unknown, TFrontmatter>
rather than as a Zod type, so the package does not dictate your validator. The
z above is re-exported from this package precisely so that extending
docFrontmatterSchema needs no install and no matching version.
Four things are worth knowing before you write one.
Let the type be inferred — never name it. Naming it explicitly and omitting the schema type-checks and then lies, because nothing validates the type you named:
// Compiles. Every extra field is `undefined` at runtime, typed as present.
const docs = createDocsRoute<MyFrontmatter>({ contentDir: 'content/docs' });Unknown keys are stripped, by Zod and by every other validator worth using.
Declare every field you intend to read — under the base schema, a page with
audience: operator parses fine and silently loses the value.
docFrontmatterSchema.extend(…) keeps the built-ins; a z.object({ … })
written from scratch does not.
The package's own fields survive a schema that forgets them. title drives
the <h1> fallback and <title>, draft the visibility filter, aliases the
redirects, order and label the sidebar. All six are re-read from the raw
YAML and laid back over your schema's output, so a custom schema can only ever
add fields — it cannot drop or corrupt the ones the package reads itself. The
price is that a .default(), .transform() or .coerce aimed at one of the
six is not honoured: the YAML wins.
That is a runtime guarantee, not a compile-time one, and the difference matters.
TFrontmatter extends DocFrontmatter constrains only title, because the rest
are optional. A z.object({ title, audience }) type-checks perfectly and used
to strip draft and aliases on the way through — publishing every draft,
submitting them to Google, and silently returning no redirects at all. Prefer
docFrontmatterSchema.extend(…) anyway: you then get the built-in fields in
your inferred type, rather than merely at runtime.
Export the schema from one module. The filesystem scan is memoised per resolved config, and two schema objects count as the same schema only when they are the same object. Build one inline in each route file and each file pays for its own scan.
A page the schema rejects fails the build, naming the file, every bad path, and whose schema rejected it:
Invalid frontmatter in api/auth.md:
- audience: Invalid option: expected one of "user"|"operator"
Fix the YAML block at the top of that file, or the `frontmatterSchema` in your docs config.frontmatterSchema sits beside every other option in
Configuration, and the codes this validation
throws are listed in Errors.