Blog, docs and legal pages
Writing the blog, docs and legal pages in MDX — file naming per language, frontmatter, the table of contents, and adding a collection of your own.
The blog, the docs you are reading and the legal pages are MDX files in the repo, compiled at build time and prerendered to static HTML. There is no CMS and no database behind them: publishing a post is committing a file.
| Collection | Folder | URL | Frontmatter |
|---|---|---|---|
| Blog | content/blog/ | /blog/<slug> | title, description, date, category, cover |
| Docs | content/docs/ | /docs/<slug> | title, description, group, order |
| Legal | content/legal/ | /legal/<slug> | title, updated |
Blog and docs belong to the optional content feature
(src/features/content/). Legal pages are core: a product that takes payments
needs terms and a privacy policy whether or not it has a blog, so they survive
when the content feature is deleted.
Files and languages
A page's slug is its file name. A file can be locale-neutral or tied to one locale:
content/docs/deploy.mdx # neutral
content/docs/deploy.zh.mdx # Chinese
content/blog/launch.en.mdx # English
content/blog/launch.zh.mdx # Chinese
For each visitor the page is picked in this order: the file for their locale,
then the neutral file, then the English one. A page you have not translated
yet still shows up — in English — at /zh/docs/<slug>, rather than dropping
out of the list. See Internationalization for how the locale is
chosen from the URL.
Frontmatter
Frontmatter is YAML at the top of the file, validated with zod in
src/features/content/collections.ts (blog and docs) and
src/core/content/legal.ts (legal). A missing or misspelled field fails the
dev server or the build, not a visitor.
A docs page:
---
title: Deploy
description: One sentence under ~155 characters. It is the meta description and the subtitle under the title.
group: start
order: 5
---
description is used twice: as the page's meta description and as the
subtitle the page renders under its title. So do not repeat it as the first
paragraph, and do not start the body with a # heading — the page prints the
title itself.
A blog post:
---
title: "D1 has no interactive transactions"
description: "What to do instead."
date: "2026-09-05"
category: specs
cover: blog-d1
---
dateis a string, and the blog index sorts by it, newest first.categoryis one ofguide,specsorexample(postCategories), shown as filter pills on/blog.coveris a file stem:blog-d1readspublic/covers/blog-d1.webp. Covers are committed next to the posts rather than uploaded anywhere, so a post and its picture ship in the same commit. The template's covers are free stock photos; replace them with your own.
Writing MDX
MDX is Markdown plus JSX. Everything in standard Markdown works, plus GFM
tables, strikethrough and task lists (remark-gfm). A wide table scrolls
inside its own box on a phone.
You can import modules at the top of a file and use them in the text. The legal pages do this to print your company name from the config:
import { appConfig } from '@/config/app-config'
This Service is operated by {appConfig.legalEntity.name}.
Two traps, because MDX parses JSX: a bare < or { in prose has to be in
backticks or escaped, and HTML comments are not allowed — write
{/* a comment */} instead.
One custom component is available in every page without an import: <Clip>,
a video with a poster frame, read from your media origin
(appConfig.mediaUrl, see Landing page and themes).
Until mediaUrl is set it renders an empty placeholder.
MDX is compiled at build time by @mdx-js/rollup. There is no runtime MDX
evaluation — workerd does not allow it — so content cannot come from a
database or an API without replacing this pipeline.
Table of contents
Every ## and ### heading gets an anchor id at compile time, and the page
receives the list of headings with it (src/core/content/remark-headings.ts).
That list becomes the table of contents: on the right of docs pages on wide
screens, and on the left of blog posts. Because it is built with the page, it
is in the prerendered HTML and can never link to an anchor that does not
exist; only the "you are here" highlight needs JavaScript.
Things that follow from how it works:
- Only top-level
##and###headings count.####and headings inside JSX are left out. - The list hides itself when a page has fewer than two headings.
- Anchors come from the heading text (lowercased, spaces to hyphens,
punctuation dropped). Chinese headings keep Chinese anchors. Renaming a
heading changes its anchor, so links to
#old-namestop jumping. - Two headings with the same text get
-1,-2suffixes.
Docs sidebar and ordering
The docs sidebar is built from frontmatter. group must be one of the
sections in docGroups (start, concepts, features, customize,
reference), shown in that order, and order sorts pages inside a group.
The previous/next links at the bottom follow the same order, and /docs
redirects to the first page.
To add a section, add its id to docGroups in
src/features/content/collections.ts, a docs_group_<id> message in
messages/en.json and messages/zh.json, and its entry in the groupLabel
map in src/routes/_marketing/docs/$slug.tsx. That map is typed as a record
over the groups, so typecheck tells you if you miss it.
Legal pages
Legal pages are one file per locale: content/legal/terms.en.mdx,
terms.zh.mdx, and the same for privacy, refunds and dmca. They carry
an updated date, printed under the title. The shipped text is a template —
have a lawyer review it before you rely on it.
The list of legal slugs is fixed in legalSlugs in
src/core/content/legal.ts, and the footer links to each one by hand in
src/routes/_marketing.tsx. A new legal page needs its files, an entry in
that list and a footer link.
How pages load
Each collection is two import.meta.glob calls over the same files: one
loads only the frontmatter (for lists, the sidebar and titles), the other
loads the page body lazily, one chunk per file. A page's route loader calls
entry.load() so the body is ready before it renders.
The split matters for speed. Frontmatter is read on every page, and reading it from the compiled MDX module would pull every post's body into the main bundle. Keep the pattern when you add a collection.
Prerendering and the sitemap
Blog, docs and legal pages are prerendered at bun run build: the prerender
crawls links from the home page, so a page is included as long as something
links to it. After the build, scripts/generate-sitemap.ts writes
sitemap.xml from the prerendered files. There is nothing to register by
hand.
Adding a collection
Say you want a changelog at /changelog/<slug>:
- Put the files in
content/changelog/. - In
src/features/content/collections.ts, add a zod schema and acollect(...)call with the two globs, copied fromposts. Both glob patterns must be string literals, because Vite rewrites them at build time. - Add routes under
src/routes/_marketing/changelog/modelled on the blog ones: the index lists entries from frontmatter, the$slugloader finds the entry withpickLocalizedand awaitsload(), and the component renders<MDXContent load={entry.load} />(plus<TableOfContents>if you want one). - Link to it from the header or footer in
src/routes/_marketing.tsx, so the prerender finds it. - Run
bun run generate-routesif the dev server is not running. - Extend the
contentrecipe inscripts/verify-deletion.tsto remove the new folder, routes and links, then runbun run verify:deletion content, so the feature stays deletable.
Removing blog and docs
The content recipe deletes src/features/content/, the blog and docs
content, routes and post covers, and the /blog and /docs links in the
header, footer and landing page. The MDX pipeline stays, because the legal
pages use it. See Deleting features.