Andrii Lysenko profile photo

Andrii Lysenko

Senior Software Engineer

Open to opportunities
Back to Blog
next.jsmdxtypescript

Building a Blog with Next.js, MDX, and Shiki

4 min read

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

PackageRole
next-mdx-remoteCompile MDX in React Server Components
gray-matterParse frontmatter cheaply on the index page
shikiSyntax highlighting via VS Code token grammar
rehype-slugAuto-generate heading id attributes for ToC anchors
reading-timeEstimate 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 route

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

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

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

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

typescript
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.

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=1 query 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 email

Opens your email client.