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.

CollectionFolderURLFrontmatter
Blogcontent/blog//blog/<slug>title, description, date, category, cover
Docscontent/docs//docs/<slug>title, description, group, order
Legalcontent/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
---
  • date is a string, and the blog index sorts by it, newest first.
  • category is one of guide, specs or example (postCategories), shown as filter pills on /blog.
  • cover is a file stem: blog-d1 reads public/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-name stop jumping.
  • Two headings with the same text get -1, -2 suffixes.

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 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>:

  1. Put the files in content/changelog/.
  2. In src/features/content/collections.ts, add a zod schema and a collect(...) call with the two globs, copied from posts. Both glob patterns must be string literals, because Vite rewrites them at build time.
  3. Add routes under src/routes/_marketing/changelog/ modelled on the blog ones: the index lists entries from frontmatter, the $slug loader finds the entry with pickLocalized and awaits load(), and the component renders <MDXContent load={entry.load} /> (plus <TableOfContents> if you want one).
  4. Link to it from the header or footer in src/routes/_marketing.tsx, so the prerender finds it.
  5. Run bun run generate-routes if the dev server is not running.
  6. Extend the content recipe in scripts/verify-deletion.ts to remove the new folder, routes and links, then run bun 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.