TL;DR
- A small docs site needs 5 packages:
next-mdx-remote,remark-gfm,rehype-pretty-code,shiki,gray-matter. -
One route (
app/docs/[[...slug]]/page.tsx) serves every page viagenerateStaticParams. -
The filesystem is the CMS. No
_meta.json, nosidebars.js, no manifest. - We killed Contentlayer, the right-side TOC, search, and an entire docs section that described a product we don't ship.
- Don't copy this if you have 500+ pages, non-dev writers, i18n, or multiple shipping versions.
Hot take: most docs frameworks are installed to solve problems the team doesn't have yet.
We rebuilt our docs on raw Next.js 16 + MDX. The whole thing is about 400 lines of code we wrote ourselves. It started because we found a docs section describing features we never shipped. Once we deleted that, we started asking what else in the stack was there because a template said so.
Most of it was. Here's what survived, file by file.
The entire dependency list
{
"next-mdx-remote": "^6.0.0",
"remark-gfm": "^4.0.1",
"rehype-pretty-code": "^0.14.3",
"shiki": "^4.0.2",
"gray-matter": "^4.0.3"
}
A docs framework plus its peer dependencies typically adds 20 to 40 packages. We have five.
The file tree
app/
docs/
[[...slug]]/
page.tsx # catch-all route + generateStaticParams
layout.tsx # sidebar + content column
content/
docs/
index.mdx
getting-started/
installation.mdx
quick-start.mdx
core-concepts/
expo-integration.mdx
supabase.mdx
components/
docs/
sidebar.tsx
mdx-components.tsx
lib/
docs.ts # ~144 lines: filesystem → nav tree
If a .mdx file exists in content/docs/, it's a page. If it doesn't, it isn't. That's the whole content model.
1. One route for every page
// app/docs/[[...slug]]/page.tsx
import { notFound } from "next/navigation"
import { getAllDocPaths, getDocBySlug } from "@/lib/docs"
export async function generateStaticParams() {
return getAllDocPaths().map((slug) => ({ slug }))
}
export default async function DocPage({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug = [] } = await params
const doc = getDocBySlug(slug)
if (!doc) notFound()
return <DocLayout doc={doc} />
}
[[...slug]] is an optional catch-all, so /docs matches with slug = [] and /docs/getting-started/installation matches with ["getting-started", "installation"]. Every page is pre-rendered at next build. There's no ISR or runtime parsing, and no stale cache to blame when something is wrong in prod.
2. The "content layer" is fs + gray-matter
This is the function that replaced Contentlayer:
// lib/docs.ts
export function getDocBySlug(slug: string[]): Doc | null {
const file = resolveDocFile(slug)
if (!file) return null
const raw = fs.readFileSync(file, "utf8")
const { data, content } = matter(raw)
return { slug, frontmatter: data as DocFrontmatter, content }
}
The helpers around it are just as boring. Simplified version:
// lib/docs.ts (simplified)
const DOCS_DIR = path.join(process.cwd(), "content/docs")
function resolveDocFile(slug: string[]): string | null {
const base = path.join(DOCS_DIR, ...slug)
const candidates = [`${base}.mdx`, path.join(base, "index.mdx")]
return candidates.find((f) => fs.existsSync(f)) ?? null
}
export function getAllDocPaths(dir = DOCS_DIR, prefix: string[] = []): string[][] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
if (entry.isDirectory()) {
return getAllDocPaths(path.join(dir, entry.name), [...prefix, entry.name])
}
if (!entry.name.endsWith(".mdx")) return []
const name = entry.name.replace(/\.mdx$/, "")
return [name === "index" ? prefix : [...prefix, name]]
})
}
The frontmatter contract is deliberately tiny:
---
title: Installation
description: Get the template running locally.
order: 1
---
It has no author, date, or category fields. Docs aren't blog posts, and metadata nobody updates starts lying within a month.
3. Rendering: MDX on the server, zero MDX runtime on the client
import { MDXRemote } from "next-mdx-remote/rsc"
import remarkGfm from "remark-gfm"
import rehypePrettyCode from "rehype-pretty-code"
import { mdxComponents } from "@/components/docs/mdx-components"
<MDXRemote
source={doc.content}
components={mdxComponents}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypePrettyCode, { theme: "github-dark-dimmed" }]],
},
}}
/>
shiki uses the same TextMate grammars as VS Code, so code in the docs looks exactly like code in the editor the reader just left. Line numbers are off and wrapping is on, because people read docs on phones and horizontal scroll on a phone is hostile.
One gotcha: rehype-pretty-code puts its own inline styles on inline <code> too, which fought our Tailwind styling. The fix is a one-line override (roughly):
// components/docs/mdx-components.tsx
export const mdxComponents = {
code: ({ style, ...props }: React.ComponentProps<"code">) => (
<code {...props} /> // drop the plugin's inline style, let Tailwind win
),
// ...other overrides
}
4. The sidebar is not alphabetical. On purpose.
The right reading order for docs is almost never alphabetical. "Getting Started" comes before "Core Concepts" even though G > C.
const sectionConfig: Record<string, { title: string; order: number }> = {
"": { title: "Overview", order: 0 },
"getting-started": { title: "Getting Started", order: 1 },
"core-concepts": { title: "Core Concepts", order: 2 },
}
const byOrder = (a?: number, b?: number) => (a ?? 99) - (b ?? 99)
sections.sort((a, b) => byOrder(sectionConfig[a.key]?.order, sectionConfig[b.key]?.order))
section.pages.sort((a, b) => byOrder(a.frontmatter.order, b.frontmatter.order))
If you add a new section without registering it in sectionConfig, it sinks to position 99. That's an intentionally ugly failure mode, because it forces whoever added the section to decide where it belongs.
The kill list
Contentlayer. It added 30 to 90 seconds per build, broke on Next.js upgrades, and gave us typed frontmatter we were already getting from 20 lines of gray-matter.
Right-side TOC. We built it, used it for a month, and deleted it. Our pages are short enough that scrolling beats scanning. On a 375px viewport, the only place a TOC fits is a dropdown nobody opens. We removed rehype-slug and rehype-autolink-headings along with it.
Search. We have no Algolia and no search bar. Most docs traffic arrives from Google, which is already a search engine, and the sidebar is faster than typing once you're on the page. Pagefind is on the shortlist for later.
A whole section about a product we don't ship. Our old docs had an "AI Integration" section describing a generic AI starter kit. That isn't what we sell. The docs now mirror the actual React Native templates in the catalog. The old file is still in the repo as ai-generation.mdx_old, a tombstone to remind us what aspirational docs cost.
Things we almost built and didn't:
- Live code editor. It would need a sandboxed runtime, a React Native web shim, and an API proxy. Copy-paste snippets won.
-
Versioned docs. We have one shipping version, and versioning a one-version product is a drawer labeled
drawer. - Separate docs dark-mode toggle. We prototyped it for a week. It meant two theme states to debug for one marginal benefit, so we binned it.
Framework vs hand-rolled, honestly
| Docs framework | Our setup | |
|---|---|---|
| Deps added | 20–40 | 5 |
| Docs code you own | ~0 (it's in node_modules) |
~400 lines |
| New page | create .mdx + edit _meta.json
|
create .mdx
|
| Layout bug | read framework source | open the file |
| Search / versioning / i18n | built in | none |
The comparison cuts both ways. A framework gives you features you don't have, and it also makes you maintain features you don't want.
Don't do this if…
- You have 500+ pages. Hand-ordering that sidebar is masochism.
- You have non-developer writers. A CMS pays for itself the first time someone without a terminal needs to fix a typo.
- You need multiple shipping versions. Docusaurus already solved that, so don't re-solve it.
- You need i18n on day one. That's framework territory.
If none of those apply, hand-rolled MDX on Next.js is probably smaller than the framework you were about to npm install.
References
What's your docs stack right now: framework, hand-rolled, or "a README we keep meaning to fix"? Drop it in the comments. I'm especially curious whether anyone has made Pagefind work nicely with next-mdx-remote.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.