Writing docs
The house style for this site — where a page lives, what shape it takes, which components are available, and what fails the build.
Add a docs page is the procedure: create the file, fill the frontmatter, order the sidebar, run the gates. This page is the standard the result is held to.
Every doc lives in apps/docs/content/docs
There is no docs/ at the repository root and none inside an app. A README may
introduce a directory in a paragraph; anything longer belongs here, with the
README linking to it. Two copies of an instruction means one of them is wrong
within a month.
Pick one shape per page
Mixing a tutorial into a reference weakens both — the reader looking something up has to skip the teaching, and the reader learning has to skip the tables.
| Shape | The reader's stance | Examples here |
|---|---|---|
| Tutorial | "I'm new — walk me through it" | Getting started |
| How-to | "I have a goal, get me there" | Guides |
| Reference | "I'm working, let me look something up" | Endpoint reference, Component reference |
| Explanation | "Help me understand why" | Architecture, OpenAPI for /api |
Voice
Say why, not just what, and put the failure mode next to the rule. A guide that lists commands without saying what breaks when you skip one is the kind of doc nobody reads twice.
Ground every instruction in what the repository does today. Read the
package.json scripts and the config you are describing rather than writing the
generic version. If a command in a guide does not run, the guide is wrong, not
the reader.
Write "you", keep the active voice, and cut the words that carry nothing — "in order to" is "to", "it is necessary to" is "you must".
Three habits worth naming because they keep appearing:
- Do not restate the title as an
# H1. The title comes from frontmatter, and a heading repeats it. - Do not open with the description. It is printed under the title already, so the first paragraph should add something.
- Date anything that will go stale. A plan, an audit, a status note — put the date in the text, not only in the filename.
Components
These are what the page can reach for. All of them work in .mdx; the ones
built from a fence or a heading work in plain .md too, which is why they are
preferred.
Directory trees
<Files> beats a table or an ASCII tree when the shape of the tree is the
point — what nests inside what. Put the entry file first: README.md at a
repository root, index.mdx in a docs folder, or page.tsx in a route.
<Files>
<Folder name="content/docs" defaultOpen>
<File name="meta.json" />
</Folder>
</Files>Use a table instead when the reader wants "what is this directory for" rather than "what is the shape" — the tables on web and api are that case.
Callouts
:::note, :::tip, :::info, :::warning, :::danger, :::success, with an
optional title in brackets.
Reserve these for consequences
A callout that says something ordinary trains people to skip callouts. Use one where ignoring the paragraph costs something.
Steps
Headings numbered ## 1. Do the thing render as a numbered list automatically —
add a docs page is doing it. Use it for a
procedure with an order; do not use it for a list of options.
Diagrams
A ```mermaid fence renders a diagram, lazily on the client. Reach for one
when the relationship is hard to hold in a sentence, as on
Architecture — not to restate a list.
Code blocks
Shiki highlights every fence. The meta after the language and the [!code ...]
comments do the rest — all of it works in .md too.
export const source = loader(docsSource, {
baseUrl: docsRoute,
icon: resolveIcon,
plugins: [openapiPlugin()],
});```ts title="src/lib/source.tsx" lineNumbers
icon: resolveIcon,
```| Write | Effect |
|---|---|
title="src/x.ts" | A filename header, with a language icon |
lineNumbers | Line numbers; lineNumbers=12 starts the count at 12 |
noCopy | Drops the copy button — use it for output, not for commands |
tab="Label" | Groups consecutive fences into tabs |
// [!code highlight] | Marks that line |
// [!code word:resolveIcon] | Marks every occurrence of a word |
// [!code ++] / // [!code --] | Renders the line as an addition or a removal |
// [!code focus] | Dims everything except the marked lines |
Two of those earn their place more than the rest. Highlight the line the
paragraph is about — a twenty-line config block with the one line marked is
read; the same block unmarked is skipped. And use ++/-- rather than
prose when the instruction is "change this to that":
{
"scripts": {
"dev": "bun run openapi && next dev --port 3001",
"predev": "bun run openapi",
"dev": "next dev --port 3001"
}
}noCopy matters for a different reason: a copy button on a block of terminal
output invites someone to paste it back into a shell.
[openapi] 357 files in content/docs/api/v1/reference
[openapi] 67 files in content/docs/api/v2/reference
348 pages, every internal /docs link resolves.The comment syntax follows the fence's language, so it is # [!code highlight]
in bash and <!-- [!code highlight] --> in HTML.
Maths
$...$ and $$...$$, through KaTeX. The odds and model pages use it.
Source images
Name and link a source in the paragraph where it supports the argument. When
the source has a useful diagram, benchmark, or product screenshot, follow that
paragraph with <SourceReference>: the image spans the page width and a normal
Fumadocs card immediately below it names the source and explains why it matters.
Do not add a detached source section or collect citations in a grid at the end of the page. That makes the reader guess which claim each link supports, and a grid gives every source the same weight even when only one shaped the argument.
The [gRPC benchmarking guide](https://grpc.io/docs/guides/benchmarking/)
separates throughput from median and tail latency under a declared load.
<SourceReference
title="Benchmarking"
description="gRPC separates QPS, median latency, and tail latency under declared load, which is why this report plots p99 separately from throughput."
href="https://grpc.io/docs/guides/benchmarking/"
image="https://grpc.io/img/logos/grpc-icon-color.png"
imageFit="contain"
/>Inlining a real file
A doc-gen:file block reads a file from the repository at build time, so the
snippet cannot drift from the code it quotes. Prefer it to pasting.
```json doc-gen:file
{ "file": "../../../../../turbo.json", "codeblock": { "lang": "json" } }
```The app pages: <TechStack> and <Feature>
Every app's overview opens the same way: a sentence on what the app is, a strip of the technologies in it, then two grids — what it does for whoever uses it, and what is worth knowing before working on it — and it ends by pointing at its own siblings. The pages beside it take the same names in the same order across apps (Features, Architecture, Data, Conventions), so one app can be read against another. The front page says what belongs on each.
<TechStack>
<Tech name="nextdotjs" label="Next.js 16">
App Router
</Tech>
<Tech name="go" />
<Tech name="Route" label="chi">
routing
</Tech>
</TechStack>
<Cards>
<Feature icon="brand:go" title="The Go service blocks production writes">
One or two sentences, and the reason rather than the rule.
</Feature>
</Cards><Tech name> is a Simple Icons slug — nextdotjs,
postgresql, revenuecat. Anything with no brand mark of its own falls back to
a Hugeicons name, which is how chi and nuqs get a glyph. label overrides the
brand's own title; the children are the four-word note on what it does here.
A mark too dark to see on the dark theme is swapped for the foreground colour,
so a black logo does not leave a hole in the strip.
<Feature icon> and the icon: frontmatter field accept a Hugeicons name,
brand:<simple-icons-slug>, or app:<workspace>. The app form uses authored
artwork synced from each workspace's assets/icon-small.png or
public/icons/icon-32x32.png, falling back to the compact green StatsHub mark
for a workspace that authored neither — shrinking the full wordmark into a
sidebar icon leaves it unreadable. The slugs are the apps/ directory names:
app:api, app:docs, app:expo, app:web. Use app artwork for a StatsHub product or surface, a brand mark for an
actual technology, and semantic Hugeicons for concepts such as errors and
filters. Every form throws when it does not resolve, so a typo fails
build:docs rather than rendering an empty box.
Everything else
<Cards>, <Tabs>, <Accordions>, <TypeTable>, <AutoTypeTable>,
<Banner>, <InlineTOC>, <ImageZoom>, <GithubInfo>, <DynamicCodeBlock>,
<Mermaid>, <OpenAPIPage>, <SourceReference>, <DocsGraph>.
apps/docs/src/components/mdx.tsx is the list that ships.
How the pages link up
<DocsGraph /> draws the site from its own link references — an edge exists
because one page actually links to another. It is the fastest way to spot a page
nothing points at, which is how a doc gets lost without ever being deleted.
The ~294 generated endpoint pages are excluded; none of them links to another,
and they would bury everything else in a ring of unconnected dots. See
src/lib/build-graph.ts.
Two rules follow from looking at it:
- Every page should be reachable from an index. A page missing from its
directory's
meta.jsonstill builds and still answers its URL — it just vanishes from the sidebar. - Link sideways, not only down. A page that explains a decision should link to the page that applies it, and back.
A bare colon starts a directive
remark-directive is what gives this site :::warning fences. It also enables
the inline form, :name[label]{attrs} — one colon, not three. So a clock
time in prose:
that has to be right at 15:00 on a Saturdayparses as the text 15 followed by a directive named 00, which renders a
<div> inside the <p>. The page still builds and still looks correct; what it
does is fail hydration in the browser, and every interactive thing below it on
that page stops working.
Wrap it in backticks — `15:00` — or reword. This had already caught three
pages before it was written down, and the symptom never points at the colon.
Nothing in the gates catches this
build:docs passes, check-links passes, and the rendered page looks right.
The only evidence is a hydration error in the browser console, which is one more
reason to open a page after writing it.
What fails the build
| Command | What it catches |
|---|---|
bun run build:docs | A frontmatter icon that is not a Hugeicons name — src/lib/icons.tsx throws rather than resolving it to nothing |
bun run lint in apps/docs | An internal /docs/... link pointing at no page, via scripts/check-links.ts |
| both | A status outside new, beta, draft, deprecated; invalid YAML in frontmatter |
dev serves all of these without complaint, which is why the gates exist. Run
them before you push.
Where state lives
Server caches own fetched data, routes own reproducible choices, and device stores own preferences. The adapter differs by platform; the ownership rule does not.
UI
The four regions every tool screen is built from, what belongs in each, and the overlay vocabulary that differs between web and mobile.