Building a Blog with Next.js, MDX, and Shiki
How I added a statically-generated MDX blog to my CV site — syntax highlighting, table of contents, tag filtering, and no CMS required.
Every developer's personal site eventually gets a blog. Mine resisted for a while — the CV was the point, not the writing. But there's a class of thing I keep wanting to link people to: detailed explanations of past decisions, notes on tools I use every day, write-ups of problems I've solved more than once. A blog is the right shape for that.
This post covers how I built it.
Goals and Constraints
Before picking tools, I wrote down what I actually wanted:
- No CMS. Posts are Markdown files in the repo. I want
git blameto be the history. - No client-side JS for rendering. Content is static. The reading experience should be fast.
- Syntax highlighting that works in dark mode without shipping a theme-switcher to the browser.
- TypeScript all the way down — frontmatter validated at build time, not discovered broken at runtime.
The Stack
The site is already a Next.js 15 app with output: "standalone". The blog adds:
| Package | Role |
|---|---|
next-mdx-remote | Compile MDX in React Server Components |
gray-matter | Parse frontmatter cheaply on the index page |
shiki | Syntax highlighting via VS Code token grammar |
rehype-slug | Auto-generate heading id attributes for ToC anchors |
reading-time | Estimate reading time from word count |
No database. No API routes. Build output is a static Node.js server image.
File Layout
content/blog/ ← MDX posts live here
lib/blog.ts ← getAllPosts, getPostSource, extractHeadings
lib/highlighter.ts ← Shiki singleton (cached across requests)
components/blog/ ← PostCard, PostHeader, TableOfContents, etc.
app/blog/page.tsx ← Index: list of all published posts
app/blog/[slug]/ ← Individual post routeThe key insight: gray-matter is used only for the index page (fast frontmatter-only parse of every file), while compileMDX from next-mdx-remote/rsc is called only for the single post being viewed.
Syntax Highlighting Without Runtime JS
Shiki's dual-theme output lets you ship light and dark code styles as pure CSS — no JavaScript theme switcher runs in the browser.
Shiki compiles each token to a <span> with two CSS custom properties: --shiki-light and --shiki-dark. Two lines of CSS in globals.css connect those to the site's .dark class:
pre span { color: var(--shiki-light) !important; }
.dark pre span { color: var(--shiki-dark) !important; }The highlighter itself is a singleton cached at module scope in lib/highlighter.ts:
import { createHighlighter, type Highlighter } from "shiki";
let highlighter: Highlighter | null = null;
export async function getHighlighter(): Promise<Highlighter> {
if (!highlighter) {
highlighter = await createHighlighter({
themes: ["github-light", "github-dark"],
langs: ["typescript", "java", "bash", "json"],
});
}
return highlighter;
}This means the grammar and theme data is loaded once, not on every request.
Frontmatter as a TypeScript Interface
Every post has a PostMeta interface enforced at parse time:
export interface PostMeta {
title: string;
date: string; // ISO 8601
slug: string;
excerpt: string;
tags: string[];
coverImage?: string;
published: boolean;
readingTime: string; // auto-calculated
}gray-matter parses the frontmatter. reading-time computes words per minute. Draft posts (published: false) are excluded in production but visible in dev — useful for previewing before publishing.
Table of Contents
The ToC is extracted from the raw MDX source with a simple regex before compilation:
export function extractHeadings(source: string): Heading[] {
const lines = source.split("\n");
return lines.flatMap((line) => {
const h3 = line.match(/^###\s+(.+)$/);
const h2 = line.match(/^##\s+(.+)$/);
if (h3) return [{ level: 3, text: h3[1].trim(), id: slugify(h3[1]) }];
if (h2) return [{ level: 2, text: h2[1].trim(), id: slugify(h2[1]) }];
return [];
});
}rehype-slug ensures the rendered headings get matching id attributes. The TableOfContents component watches which heading is in the viewport using IntersectionObserver — the same pattern the sidebar uses to track the active CV section.
Navigation
The existing nav is scroll-spy based — IntersectionObserver, no usePathname. The Blog link is different: it's a route, not a section. Rather than retrofitting NavItem with an href field, I added the Blog link as a separate element after the navItems.map() loop in both Sidebar and MobileNav, using pathname.startsWith("/blog") for active state.
What's Next
- RSS feed (
app/blog/feed.xml/route.ts) - Search (client-side, index built at build time)
- Draft preview via a
?preview=1query parameter
For now: the plumbing works, the highlighting is fast, and the content is just files.
Download my CV (PDF)Stay in the loop
When a new post goes up, I send a short note. No list, no newsletter platform — just a direct email.
Send me an emailOpens your email client.
