content-cms — typed content, designed prose
Stage: Phase 7 — Backend - Reads: design/BRIEF.md, design/DIRECTION.md, design/SYSTEM.md - Writes: content/*, mdx-components.tsx, next.config.ts MDX wiring, frontmatter schema, lib/highlight.ts, prose styles in app/globals.css
Standard
- Content is typed at the boundary: frontmatter validated by a zod v4 schema at build time. A typo'd field is a build error, not a silent
undefined rendered to production.
- Long-form prose consumes SYSTEM.md tokens — accent links, tinted-neutral code blocks, the site's type scale. Default gray prose classes on a designed site read as a template drop-in.
- Contentlayer is DEAD. Never recommend, install, or migrate toward it. Migrate away from it to content-collections.
- Every content page ships real metadata derived from its frontmatter, and drafts never leak — not into listings, not into sitemap.ts.
- Code blocks are highlighted at build inside RSCs — a highlighter in the browser is a defect, not a choice.
Process
- Read design/BRIEF.md: what content exists, who edits it, how often. That decides the pipeline (table below).
- Wire the pipeline — @next/mdx's two mandatory wires, or the collection tool's config with the zod frontmatter schema.
- Build the prose layer AND the highlighter (component mapping, long-form styles from
@theme tokens, lib/highlight.ts + the code-surface tokens) BEFORE writing content, so the first article renders designed.
- Build routes: listing +
[slug] with generateStaticParams, generateMetadata from frontmatter, drafts filtered everywhere.
- Hand every string to
copywriting; wire posts into sitemap.ts and JSON-LD via seo.
- Verify:
npm run build compiles every content file; deliberately break one frontmatter field and confirm the build fails — a schema that can't fail isn't validating.
Pick the pipeline
- ≤ ~8 standalone long-form pages (about, legal, a manifesto) → plain
@next/mdx; the MDX files ARE routes (app/(marketing)/about/page.mdx).
- Collections — blog, changelog, docs, case studies with listings, tags, ordering → content-collections (install
@content-collections/core + @content-collections/next; the bare content-collections npm name is the project's own installer CLI (versions stop at 0.2.x), not the library) or velite — versions per stack/versions.json: frontmatter validated at build, typed arrays exported to code.
- Non-developer editors, weekly+ cadence, preview/scheduling workflows → headless CMS (last section).
- Contentlayer / next-contentlayer → never, under any prompt. Dead project.
Plain @next/mdx — the two mandatory wires
// next.config.ts
import type { NextConfig } from 'next'
import createMDX from '@next/mdx'
const nextConfig: NextConfig = {
pageExtensions: ['md', 'mdx', 'ts', 'tsx'], // without md/mdx here, .mdx pages 404 with ZERO error
}
export default createMDX()(nextConfig)
// mdx-components.tsx — PROJECT ROOT, beside app/ (not inside it).
// Omitting this file is the #1 MDX setup error, and the failure message never names it.
import type { MDXComponents } from 'mdx/types'
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
h2: (props) => <h2 className="mt-14 mb-5 font-display text-3xl tracking-tight" {...props} />,
a: (props) => <a className="text-accent underline decoration-accent/40 underline-offset-4 hover:decoration-accent" {...props} />,
...components,
}
}
- Pin
@next/mdx to the exact next version (they move together — stack/versions.json locks the pair) — a mismatch is build-breaking drift.
- Install the companion packages the current docs list alongside it (loader/react/types) — verify against current docs first.
- @next/mdx parses NO YAML frontmatter by default — a
--- block renders as literal text. For page.mdx routes, export const metadata = { title, description } feeds the Metadata API like any page. remark-frontmatter is possible, but Turbopack requires serializable plugin config — verify against current docs first.
Typed collections — content-collections or velite
Both validate frontmatter at build and emit typed, importable arrays. Exact config wiring (Next plugin wrapper, schema helper) drifts between minors — verify against current docs first, then record the choice in design/BRIEF.md. The schema shape, whichever tool:
const frontmatter = z.object({
title: z.string().min(8, { error: 'Titles under 8 chars read as filler' }),
date: z.iso.date({ error: 'YYYY-MM-DD' }),
summary: z.string().min(40, { error: 'Write a real summary — it becomes the meta description' }).max(180),
tags: z.array(z.string()).max(4).default([]),
draft: z.boolean().default(false),
})
- Computed fields at build: slug from filename, reading time, date-sorted exports. Never compute these at request time from raw files.
- Filter
draft out of every production list, every generateStaticParams, AND sitemap.ts — one forgotten surface leaks the post.
// app/(marketing)/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
export function generateStaticParams() {
return posts.filter((p) => !p.draft).map((p) => ({ slug: p.slug }))
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params // Next 16: params is a Promise — always await
const post = getPost(slug)
if (!post || post.draft) notFound() // filtered generateStaticParams alone does NOT block this
return { title: post.title, description: post.summary }
}
- Guard the page component the same way (
if (!post || post.draft) notFound()) — or set export const dynamicParams = false. dynamicParams defaults to true, so a filtered generateStaticParams still renders a draft on demand at its direct URL.
Prose that consumes the system
- Never ship
prose prose-gray / prose-slate defaults. Long-form type is still YOUR type system — gray plugin prose is the greppable smell of an undesigned article page.
- Two mechanisms, use both: (1) component mapping —
mdx-components.tsx (and the components prop where collections render MDX) maps h1–h6/p/a/blockquote/code to token-styled elements; (2) one long-form class in app/globals.css built from @theme tokens for element rules the mapping shouldn't repeat.
- The numbers: measure 65–75ch (
max-w-[70ch]); body leading 1.65–1.75; h2 takes ≥2× the space above than below (mt-14/mb-5) so sections group; links take the site accent with underline-offset-4, never default blue or gray; inline code and blockquotes sit on the tinted neutral ramp from SYSTEM.md §color, not bg-gray-100.
- Dark mode re-decided for prose: code-block backgrounds, blockquote borders, image borders get explicit dark values — inversion muddies long-form worst of all.
- Prose headings use the display font only if DIRECTION.md's type stance says so — editorial directions often keep them in the body family with weight contrast instead.
Code blocks — Shiki, RSC-only
Shiki (per STACK.md) is the highlighter, and it runs at build time inside a server component — a highlighted block ships styled HTML and zero client JavaScript. A client-side highlighter (prismjs, highlight.js, react-syntax-highlighter) sends a parser plus every grammar to the browser to redo work the server already finished, and flashes unstyled code while it boots.
- In MDX —
@shikijs/rehype (per STACK.md) wired into the pipeline config; every fenced block is highlighted during the build. Turbopack requires serializable plugin options, so verify how transformers are passed against current docs — where that fails, map pre/code in mdx-components.tsx to the same RSC component below and keep one path.
- Outside MDX —
codeToHtml() called directly in an RSC. Standalone marketing snippets (a dev-tool hero showing the install line, a feature section with three lines of API) render through this same component, never a second hand-styled <pre>; one code aesthetic site-wide.
// lib/highlight.ts — server-only. Themes are a base; the token colours are mapped onto the site's palette.
import { codeToHtml } from "shiki/bundle/web"; // not the full `shiki` barrel (per STACK.md)
import { transformerNotationDiff, transformerNotationHighlight } from "@shikijs/transformers";
export const highlight = (code: string, lang: string) =>
codeToHtml(code, {
lang,
themes: { light: "github-light", dark: "github-dark" }, // base themes only — nearest to the palette's hue family
defaultColor: false, // emit --shiki-light / --shiki-dark instead of baked colors
// + the colour-replacement map that swaps the themes' palette entries for the site's var(--color-*)
// — verify the current option name against the docs before writing it
transformers: [transformerNotationDiff(), transformerNotationHighlight()],
});
// components/code-block.tsx — server component; only <CopyButton> below is "use client"
export async function CodeBlock({ code, lang }: { code: string; lang: string }) {
return <div className="relative"><CopyButton value={code} /><div dangerouslySetInnerHTML={{ __html: await highlight(code, lang) }} /></div>;
}
defaultColor: false is half the trick: Shiki emits paired custom properties per token and bakes no inline color, so a few CSS rules can map them onto the site's own tokens — and that swap is won by @layer components order, not the !important the docs reach for and gate-code sweeps for (per STACK.md). The other half is the palette: the stock pair earns its keep as scope coverage only, its colour entries replaced by the site's var(--color-*) values, or you author a minimal theme object from SYSTEM.md §color. Never ship a stock VS Code theme untouched; Dracula on a luxury-serif site is the same tell as untouched shadcn. And mind the entry point: the full shiki barrel carries every grammar and theme — build-time or not, prefer shiki/bundle/web or createHighlighterCore with explicit languages (per STACK.md).
/* app/globals.css — the code surface is the tinted neutral ramp from SYSTEM.md §color, both themes re-decided */
:root { --code-surface: oklch(0.96 0.008 85); }
.dark { --code-surface: oklch(0.21 0.018 60); } /* re-decided, never inverted */
@theme inline { --color-code-surface: var(--code-surface); } /* the bridge ultraweb:tokens requires */
@layer components {
.shiki { background: var(--color-code-surface); } /* surface: site token, not the theme's */
.shiki, .shiki span { color: var(--shiki-light); } /* tokens: Shiki's paired variables */
.dark .shiki, .dark .shiki span { color: var(--shiki-dark); }
}
@shikijs/transformers supplies the // [!code ++] / // [!code highlight] notations — annotations written in the source, styled by your CSS, still zero runtime. The copy button is the one client leaf: a "use client" button positioned beside the server-rendered <pre>, taking the raw string as a prop. The block never becomes a client component just to carry a button.
When a headless CMS instead
- MDX-in-repo is the default: versioned with the site, edited by whoever edits code, zero runtime dependency.
- Escalate only when the BRIEF names non-developer editors on a weekly+ cadence, preview/scheduling workflows, or >~200 documents with structured relations. Then the CMS is just a data source: fetch behind
'use cache' + cacheLife('hours') + cacheTag per content type, per data-fetching.
- Never bolt a CMS onto a five-page brochure site — infrastructure cosplay.
Anti-patterns
contentlayer / next-contentlayer in package.json — dead project; greppable, zero tolerance.
- No root
mdx-components.tsx while @next/mdx is installed — the #1 setup error.
pageExtensions missing 'md', 'mdx' — MDX pages 404 silently.
@next/mdx version ≠ next version — version-locked pair.
prose prose-gray, prose-slate, prose-zinc — greppable; default gray prose on a designed site.
react-syntax-highlighter, prismjs, highlight.js in package.json — a client-side highlighter redoing at runtime what Shiki did at build.
"use client" on the code-block component — only the copy button is a client leaf; the highlighted <pre> stays server-rendered.
- YAML
--- frontmatter in a plain @next/mdx page — renders as body text; export metadata instead.
post.frontmatter.title reached without a schema — typos compile and render undefined.
- Drafts visible in production listings or sitemap.ts.
- Draft reachable via direct URL —
generateStaticParams filtering alone does not block on-demand rendering (dynamicParams defaults to true); guard with notFound() or set dynamicParams = false.
- Lorem ipsum or "First post!" filler in content/ — copy is design (taste bans it outright).
Worked example — Aldermoor Trust, MDX stories volunteers maintain
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: content-cms3description: The content layer for a Next.js 16 site — plain @next/mdx for a handful of long-form pages (root mdx-components.tsx is mandatory, pageExtensions must include md/mdx, @next/mdx pinned to the next version), content-collections or velite for typed collections at scale, zod v4 frontmatter schemas, long-form prose styled from SYSTEM.md tokens instead of default gray prose classes, and Shiki code blocks highlighted at build inside RSCs (zero client JS). Contentlayer is dead and never an option. Invoke during the backend phase when the brief calls for a blog, docs, changelog, case studies, or any markdown-driven content, when choosing between MDX-in-repo and a headless CMS, or when article pages render as unstyled or default-gray prose. Trigger phrases — "add a blog", "MDX pages", "docs section", "markdown content", "frontmatter", "content collections", "style the article page", "syntax highlighting", "code blocks", "do we need a CMS".4---56# content-cms — typed content, designed prose78**Stage:** Phase 7 — Backend - **Reads:** design/BRIEF.md, design/DIRECTION.md, design/SYSTEM.md - **Writes:** content/*, mdx-components.tsx, next.config.ts MDX wiring, frontmatter schema, lib/highlight.ts, prose styles in app/globals.css910## Standard1112- Content is typed at the boundary: frontmatter validated by a zod v4 schema at build time. A typo'd field is a build error, not a silent `undefined` rendered to production.13- Long-form prose consumes SYSTEM.md tokens — accent links, tinted-neutral code blocks, the site's type scale. Default gray prose classes on a designed site read as a template drop-in.14- **Contentlayer is DEAD.** Never recommend, install, or migrate toward it. Migrate away from it to content-collections.15- Every content page ships real metadata derived from its frontmatter, and drafts never leak — not into listings, not into sitemap.ts.16- Code blocks are highlighted at build inside RSCs — a highlighter in the browser is a defect, not a choice.1718## Process19201. Read design/BRIEF.md: what content exists, who edits it, how often. That decides the pipeline (table below).212. Wire the pipeline — @next/mdx's two mandatory wires, or the collection tool's config with the zod frontmatter schema.223. Build the prose layer AND the highlighter (component mapping, long-form styles from `@theme` tokens, `lib/highlight.ts` + the code-surface tokens) BEFORE writing content, so the first article renders designed.234. Build routes: listing + `[slug]` with `generateStaticParams`, `generateMetadata` from frontmatter, drafts filtered everywhere.245. Hand every string to `copywriting`; wire posts into sitemap.ts and JSON-LD via `seo`.256. Verify: `npm run build` compiles every content file; deliberately break one frontmatter field and confirm the build fails — a schema that can't fail isn't validating.2627## Pick the pipeline2829- **≤ ~8 standalone long-form pages** (about, legal, a manifesto) → plain `@next/mdx`; the MDX files ARE routes (`app/(marketing)/about/page.mdx`).30- **Collections** — blog, changelog, docs, case studies with listings, tags, ordering → **content-collections** (install `@content-collections/core` + `@content-collections/next`; the bare `content-collections` npm name is the project's own installer CLI (versions stop at 0.2.x), not the library) or **velite** — versions per stack/versions.json: frontmatter validated at build, typed arrays exported to code.31- **Non-developer editors, weekly+ cadence, preview/scheduling workflows** → headless CMS (last section).32- **Contentlayer / next-contentlayer** → never, under any prompt. Dead project.3334## Plain @next/mdx — the two mandatory wires3536```ts37// next.config.ts38import type { NextConfig } from 'next'39import createMDX from '@next/mdx'4041const nextConfig: NextConfig = {42 pageExtensions: ['md', 'mdx', 'ts', 'tsx'], // without md/mdx here, .mdx pages 404 with ZERO error43}4445export default createMDX()(nextConfig)46```4748```tsx49// mdx-components.tsx — PROJECT ROOT, beside app/ (not inside it).50// Omitting this file is the #1 MDX setup error, and the failure message never names it.51import type { MDXComponents } from 'mdx/types'5253export function useMDXComponents(components: MDXComponents): MDXComponents {54 return {55 h2: (props) => <h2 className="mt-14 mb-5 font-display text-3xl tracking-tight" {...props} />,56 a: (props) => <a className="text-accent underline decoration-accent/40 underline-offset-4 hover:decoration-accent" {...props} />,57 ...components,58 }59}60```6162- Pin `@next/mdx` to the exact `next` version (they move together — stack/versions.json locks the pair) — a mismatch is build-breaking drift.63- Install the companion packages the current docs list alongside it (loader/react/types) — verify against current docs first.64- @next/mdx parses NO YAML frontmatter by default — a `---` block renders as literal text. For `page.mdx` routes, `export const metadata = { title, description }` feeds the Metadata API like any page. remark-frontmatter is possible, but Turbopack requires serializable plugin config — verify against current docs first.6566## Typed collections — content-collections or velite6768Both validate frontmatter at build and emit typed, importable arrays. Exact config wiring (Next plugin wrapper, schema helper) drifts between minors — verify against current docs first, then record the choice in design/BRIEF.md. The schema shape, whichever tool:6970```ts71const frontmatter = z.object({72 title: z.string().min(8, { error: 'Titles under 8 chars read as filler' }),73 date: z.iso.date({ error: 'YYYY-MM-DD' }),74 summary: z.string().min(40, { error: 'Write a real summary — it becomes the meta description' }).max(180),75 tags: z.array(z.string()).max(4).default([]),76 draft: z.boolean().default(false),77})78```7980- Computed fields at build: slug from filename, reading time, date-sorted exports. Never compute these at request time from raw files.81- Filter `draft` out of every production list, every `generateStaticParams`, AND sitemap.ts — one forgotten surface leaks the post.8283```tsx84// app/(marketing)/blog/[slug]/page.tsx85import { notFound } from 'next/navigation'8687export function generateStaticParams() {88 return posts.filter((p) => !p.draft).map((p) => ({ slug: p.slug }))89}9091export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {92 const { slug } = await params // Next 16: params is a Promise — always await93 const post = getPost(slug)94 if (!post || post.draft) notFound() // filtered generateStaticParams alone does NOT block this95 return { title: post.title, description: post.summary }96}97```9899- Guard the page component the same way (`if (!post || post.draft) notFound()`) — or set `export const dynamicParams = false`. `dynamicParams` defaults to `true`, so a filtered `generateStaticParams` still renders a draft on demand at its direct URL.100101## Prose that consumes the system102103- Never ship `prose prose-gray` / `prose-slate` defaults. Long-form type is still YOUR type system — gray plugin prose is the greppable smell of an undesigned article page.104- Two mechanisms, use both: (1) component mapping — `mdx-components.tsx` (and the `components` prop where collections render MDX) maps h1–h6/p/a/blockquote/code to token-styled elements; (2) one long-form class in app/globals.css built from `@theme` tokens for element rules the mapping shouldn't repeat.105- The numbers: measure 65–75ch (`max-w-[70ch]`); body leading 1.65–1.75; h2 takes ≥2× the space above than below (mt-14/mb-5) so sections group; links take the site accent with `underline-offset-4`, never default blue or gray; inline code and blockquotes sit on the tinted neutral ramp from SYSTEM.md §color, not `bg-gray-100`.106- Dark mode re-decided for prose: code-block backgrounds, blockquote borders, image borders get explicit dark values — inversion muddies long-form worst of all.107- Prose headings use the display font only if DIRECTION.md's type stance says so — editorial directions often keep them in the body family with weight contrast instead.108109## Code blocks — Shiki, RSC-only110111Shiki (per STACK.md) is the highlighter, and it runs at **build time inside a server component** — a highlighted block ships styled HTML and zero client JavaScript. A client-side highlighter (`prismjs`, `highlight.js`, `react-syntax-highlighter`) sends a parser plus every grammar to the browser to redo work the server already finished, and flashes unstyled code while it boots.112113- **In MDX** — `@shikijs/rehype` (per STACK.md) wired into the pipeline config; every fenced block is highlighted during the build. Turbopack requires serializable plugin options, so verify how transformers are passed against current docs — where that fails, map `pre`/`code` in `mdx-components.tsx` to the same RSC component below and keep one path.114- **Outside MDX** — `codeToHtml()` called directly in an RSC. Standalone marketing snippets (a dev-tool hero showing the install line, a feature section with three lines of API) render through this same component, never a second hand-styled `<pre>`; one code aesthetic site-wide.115116```tsx117// lib/highlight.ts — server-only. Themes are a base; the token colours are mapped onto the site's palette.118import { codeToHtml } from "shiki/bundle/web"; // not the full `shiki` barrel (per STACK.md)119import { transformerNotationDiff, transformerNotationHighlight } from "@shikijs/transformers";120121export const highlight = (code: string, lang: string) =>122 codeToHtml(code, {123 lang,124 themes: { light: "github-light", dark: "github-dark" }, // base themes only — nearest to the palette's hue family125 defaultColor: false, // emit --shiki-light / --shiki-dark instead of baked colors126 // + the colour-replacement map that swaps the themes' palette entries for the site's var(--color-*)127 // — verify the current option name against the docs before writing it128 transformers: [transformerNotationDiff(), transformerNotationHighlight()],129 });130131// components/code-block.tsx — server component; only <CopyButton> below is "use client"132export async function CodeBlock({ code, lang }: { code: string; lang: string }) {133 return <div className="relative"><CopyButton value={code} /><div dangerouslySetInnerHTML={{ __html: await highlight(code, lang) }} /></div>;134}135```136137`defaultColor: false` is half the trick: Shiki emits paired custom properties per token and bakes no inline `color`, so a few CSS rules can map them onto the site's own tokens — and that swap is won by `@layer components` order, not the `!important` the docs reach for and `gate-code` sweeps for (per STACK.md). The other half is the palette: the stock pair earns its keep as scope coverage only, its colour entries replaced by the site's `var(--color-*)` values, or you author a minimal theme object from SYSTEM.md §color. Never ship a stock VS Code theme untouched; Dracula on a luxury-serif site is the same tell as untouched shadcn. And mind the entry point: the full `shiki` barrel carries every grammar and theme — build-time or not, prefer `shiki/bundle/web` or `createHighlighterCore` with explicit languages (per STACK.md).138139```css140/* app/globals.css — the code surface is the tinted neutral ramp from SYSTEM.md §color, both themes re-decided */141:root { --code-surface: oklch(0.96 0.008 85); }142.dark { --code-surface: oklch(0.21 0.018 60); } /* re-decided, never inverted */143144@theme inline { --color-code-surface: var(--code-surface); } /* the bridge ultraweb:tokens requires */145146@layer components {147 .shiki { background: var(--color-code-surface); } /* surface: site token, not the theme's */148 .shiki, .shiki span { color: var(--shiki-light); } /* tokens: Shiki's paired variables */149 .dark .shiki, .dark .shiki span { color: var(--shiki-dark); }150}151```152153`@shikijs/transformers` supplies the `// [!code ++]` / `// [!code highlight]` notations — annotations written in the source, styled by your CSS, still zero runtime. The **copy button is the one client leaf**: a `"use client"` button positioned beside the server-rendered `<pre>`, taking the raw string as a prop. The block never becomes a client component just to carry a button.154155## When a headless CMS instead156157- MDX-in-repo is the default: versioned with the site, edited by whoever edits code, zero runtime dependency.158- Escalate only when the BRIEF names non-developer editors on a weekly+ cadence, preview/scheduling workflows, or >~200 documents with structured relations. Then the CMS is just a data source: fetch behind `'use cache'` + `cacheLife('hours')` + `cacheTag` per content type, per `data-fetching`.159- Never bolt a CMS onto a five-page brochure site — infrastructure cosplay.160161## Anti-patterns162163- `contentlayer` / `next-contentlayer` in package.json — dead project; greppable, zero tolerance.164- No root `mdx-components.tsx` while `@next/mdx` is installed — the #1 setup error.165- `pageExtensions` missing `'md', 'mdx'` — MDX pages 404 silently.166- `@next/mdx` version ≠ `next` version — version-locked pair.167- `prose prose-gray`, `prose-slate`, `prose-zinc` — greppable; default gray prose on a designed site.168- `react-syntax-highlighter`, `prismjs`, `highlight.js` in package.json — a client-side highlighter redoing at runtime what Shiki did at build.169- `"use client"` on the code-block component — only the copy button is a client leaf; the highlighted `<pre>` stays server-rendered.170- YAML `---` frontmatter in a plain @next/mdx page — renders as body text; export metadata instead.171- `post.frontmatter.title` reached without a schema — typos compile and render `undefined`.172- Drafts visible in production listings or sitemap.ts.173- Draft reachable via direct URL — `generateStaticParams` filtering alone does not block on-demand rendering (`dynamicParams` defaults to `true`); guard with `notFound()` or set `dynamicParams = false`.174- Lorem ipsum or "First post!" filler in content/ — copy is design (taste bans it outright).175176## Worked example — Aldermoor Trust, MDX stories volunteers maintain177178Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.179180## Composes with181182Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.