How I build blogs with MDX
The full pipeline behind this blog - frontmatter, filesystem reads, server-side Shiki highlighting, component overrides, and a table of contents parsed from the raw source.
Every post on this site is a .mdx file in a folder. No database, no CMS, no
admin panel. Writing means creating a file and committing it.
I have built the CMS-backed version of this before and regretted it, so this one is deliberately boring. What is not boring is the number of small ways it can quietly break - I shipped three of them into this exact pipeline, and I will point them out as we go rather than pretend the first draft worked.
That sounds almost too simple to be worth a post, but the details are where it gets interesting. MDX sits at an awkward intersection: it is a document format, a compiler target, and a React component all at once. Decide wrongly about where each of those lives and you end up shipping a markdown parser to the browser, or fighting hydration mismatches, or maintaining a table of contents that silently disagrees with the article it describes.
This is how the pipeline is put together, and - more usefully - why each piece earns its place.
Content as files, not records
The first decision is where posts live. The reflex in a Next.js app is to reach for a database or a headless CMS, because that is what you would do for any other kind of data.
A blog is the one part of a site where the content is a document. Modelling it as rows buys you an editing interface, and costs you a schema migration story, a network round trip on every render, a caching layer to undo that round trip, and a second source of truth that your Git history knows nothing about. For a personal site, that trade is upside down.
Files give you version control for free. A typo fix is a diff. A draft is a
branch. Rolling back a bad edit is git revert, not a database restore. And
because the content ships with the code, the whole site builds from a single
checkout with no credentials.
Frontmatter carries the metadata
Each file opens with a YAML block that gray-matter splits from the body:
function parseFile(slug: string) {
const raw = readFileSync(join(BLOG_DIR, `${slug}.mdx`), "utf8");
const { content, data } = matter(raw);
return { content, frontmatter: data as PostFrontmatter };
}data becomes the typed PostFrontmatter - title, excerpt, date, category,
tags, and an optional featured flag. content is the MDX body, untouched.
The important property is that one file holds both. A post can never half-exist with metadata pointing at prose that was never written, or an orphaned draft with no title. They move together through every branch, rebase, and revert.
The as PostFrontmatter cast is the one soft spot: YAML is parsed at runtime, so
TypeScript is trusting me rather than checking. On a solo blog that is an
acceptable risk - a missing field shows up immediately in dev. On a site with
several authors I would validate with Zod at read time and fail the build loudly
rather than render undefined into a heading.
The filename is the slug
getPostSlugs() reads the directory and strips the extension:
export function getPostSlugs(): string[] {
return readdirSync(BLOG_DIR)
.filter((f) => /\.mdx?$/.test(f))
.map((f) => f.replace(/\.mdx?$/, ""));
}That single convention gives routing, generateStaticParams, and related-post
lookups a stable key with nothing to keep in sync. There is no slug field in
frontmatter to contradict the filename, because there is no slug field at all.
It also means renaming a file changes its URL - which is exactly the warning you want. A rename shows up in the diff as a delete plus an add, and you are reminded to add a redirect.
Rendering happens on the server
The body is compiled with next-mdx-remote/rsc inside a server component. This
is the single most consequential choice in the whole setup, because it decides
what the reader downloads.
export async function MDXContent({ source }: { source: string }) {
const { content } = await compileMDX({
source,
components: mdxComponents,
options: { mdxOptions: { remarkPlugins, rehypePlugins } },
});
return <div className="prose prose-neutral dark:prose-invert">{content}</div>;
}The MDX compiler, the remark and rehype plugins, and the syntax highlighter all run at build time on the server. What reaches the browser is HTML. A reader on a phone downloads the article, not the machinery that produced it.
Compare that with client-side markdown rendering, where the parser, the plugin chain, and often a highlighting theme are all in the bundle, and the article cannot paint until that JavaScript has downloaded, parsed, and executed.
A small plugin stack
Three plugins cover everything a technical post needs:
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, { theme: "github-dark-dimmed", keepBackground: true }],
],remark-gfm adds GitHub-flavoured markdown - tables, task lists, strikethrough,
and autolinked URLs. Without it, a pasted table renders as a wall of pipes.
rehype-slug adds an id to every heading. That is what makes
/blog/post#section-name work, and what lets the table of contents link
anywhere in the article. It runs on the compiled tree, so the ids it generates
follow the same slugification rules every time.
rehype-pretty-code wraps Shiki, the same highlighter VS Code uses. Because it
runs at build time, the highlighted markup is baked into the static HTML - every
token arrives pre-coloured with zero client-side JavaScript. That is a real
difference from the common alternatives, which ship a highlighter and re-tokenise
the same code in every visitor's browser.
Themes without a flash
Shiki emits fixed colours, which is a problem for a site with a theme toggle. Highlighting for one theme means the other looks wrong.
The fix is to emit both palettes as CSS variables and let CSS choose:
themes: { light: "github-light", dark: "github-dark-dimmed" },
defaultColor: false,Each token then carries --shiki-light and --shiki-dark, and a small rule
picks one:
.shiki-block.shiki span {
color: var(--shiki-light);
}
.dark.shiki-block.shiki span {
color: var(--shiki-dark);
}Switching themes recolours the code instantly, because both palettes are already in the markup. Nothing re-highlights, nothing re-renders, and there is no flash of wrongly-coloured code on first paint.
Component overrides for the pieces markdown lacks
Plain markdown has no concept of a copy button, a callout, or a live playground. The usual response is to invent syntax for them. MDX offers something better: map element names to React components.
export const mdxComponents: MDXComponents = {
pre: CopyPre,
LiveCode,
a: (props) => <a {...props} className="underline underline-offset-4" />,
};Overriding pre means every fenced code block in every post - past, present,
and future - renders with a copy button. I did not have to touch a single .mdx
file to add that feature.
CopyPre reads the code text out of the DOM on click rather than accepting it
as a prop:
const text = ref.current?.innerText ?? "";
await navigator.clipboard.writeText(text);That looks like a shortcut, but it is deliberate. rehype-pretty-code nests
tokens in a deep tree of coloured spans, and that structure is an implementation
detail that changes between versions. Reading innerText gets the rendered code
regardless of how it is marked up - the component keeps working when the
highlighter's output changes underneath it.
LiveCode is the other direction: a component posts can call explicitly when
prose is not enough and the reader should be able to poke at something.
A table of contents from the raw source
The contents list is parsed from the MDX text before compilation, not from the rendered DOM afterwards:
function extractToc(content: string): TocItem[] {
const toc: TocItem[] = [];
let inFence = false;
for (const line of content.split("\n")) {
if (/^\s*```/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = /^(#{2,3})\s+(.*)$/.exec(line);
if (match) {
const text = match[2].replace(/[*_`]/g, "").trim();
toc.push({ id: slugify(text), text, level: match[1].length as 2 | 3 });
}
}
return toc;
}Two details matter here.
The first is the fence tracking. A shell comment like # install dependencies
inside a bash block looks exactly like an H1 to a naive regex. Without the
inFence flag, every code comment starting with # would appear in the
contents. This is the bug that catches everyone who writes this function
quickly.
The second is that the ids must match what rehype-slug produces. My slugify
lowercases, strips punctuation, and hyphenates spaces - the same transformation,
implemented twice. That duplication is the fragile part of this design, and the
honest alternative is to read ids off the compiled tree instead. I keep the
source parser because it also gives me headings before rendering, which the
layout needs, but it is a trade rather than a clear win.
Parsing the source also means no client-side DOM walk, no useEffect that
measures headings after paint, and no flash of missing contents while that runs.
The list is in the HTML from the first byte.
Key takeaways
- Files beat records for content that is a document. Version control, diffs, and branches come free.
- Frontmatter keeps metadata and prose together, so the two cannot drift apart or half-exist.
- Compile on the server. Highlighting at build time means no parser and no highlighter in the bundle.
- Emit both theme palettes as CSS variables so a theme toggle recolours code instantly instead of re-highlighting it.
- Override components instead of inventing syntax for what markdown lacks - one mapping upgrades every post at once.
- Parse the source, not the DOM, for structural things like a contents list, and remember to skip fenced code while you do it.
FAQ
Why not @next/mdx with file-based pages?
It works well when each post is literally a route and you never need the body as
data. Here the raw string is needed in three other places - reading time, the
contents parser, and related-post scoring - so compiling from a string is the
better fit. The two approaches can coexist; this project keeps a
mdx-components.tsx at the root so both resolve the same overrides.
Does Shiki slow down builds?
It loads a full TextMate grammar per language, which is not free. It runs once per post at build time and the result is baked into the static output, so readers never pay for it. At a few dozen posts it is not noticeable; at several hundred you would want to narrow the languages you load.
How is reading time calculated?
Word count over the raw body divided by 200 words per minute, so it scales with the actual length of each post. It is an estimate presented as an estimate - precision here would be false confidence, since nobody reads code blocks at prose speed anyway.
How are related posts chosen?
Shared tags are worth two points, a shared category one, and ties break by recency. It is deliberately crude: with a few dozen posts, anything more elaborate is harder to reason about without being noticeably better.
Can posts include interactive demos?
Yes - that is what the LiveCode override is for. The snippet is evaluated in
the browser with the animation helpers already in scope, so a post about motion
can show the thing rather than describe it.
What about drafts?
A file that has not been committed does not exist as far as the build is
concerned, which covers most of it. A draft: true frontmatter flag filtered out
in getAllPosts handles the rest.
Conclusion
The whole pipeline is about a hundred lines: read a file, split the frontmatter, compile the body, parse the headings. Nothing in it is clever, and that is the point.
The failure mode for a personal blog is not that the rendering pipeline is too simple - it is that you build so much machinery around the writing that you stop doing the writing. Every piece here exists to answer a question a reader would notice: can I copy this code, can I jump to that section, does the code match my theme, and did the page arrive fast. Everything else is a distraction dressed up as engineering.