Building This Blog: MDX, Static HTML, and SEO on Next.js 16

How I added a multilingual MDX blog to this portfolio: the rendering pipeline, the SEO surface, and the decisions that keep it fully static.

3 min read

TL;DR

This blog is plain MDX files rendered at build time by @next/mdx, with metadata handled by a separate Node-side pipeline — no CMS, no runtime rendering, and an honest hreflang matrix for mixed-translation posts.

On this page

This site used to be a single page. That was fine when the goal was "show my work," but I kept running into the same problem: I would solve something interesting at work, write the notes in a private doc, and never publish them. A blog lowers the cost of publishing to "write a markdown file," so I built one.

This post walks through how it works. If you are adding MDX to a Next.js 16 app with next-intl, most of this transfers directly.

The pipeline

The content lives in content/blog/<locale>/<slug>.mdx. Each file has YAML frontmatter — title, description, dates, tags — validated with zod at build time. Two separate systems touch each post:

  1. Metadata is read in Node with gray-matter. This powers the index, RSS, sitemap, JSON-LD, and generateMetadata. It never goes through the MDX compiler.
  2. Rendering is done by @next/mdx through a dynamic import in the article page. The compiler never sees the frontmatter — remark-frontmatter strips it.

Splitting these two concerns was the most useful decision in the whole build. Frontmatter is data; MDX is presentation. Tools that try to make one pipeline serve both end up fighting the bundler.

Turbopack changes the plugin rules

Next.js 16 builds with Turbopack by default, and that has one consequence that is easy to miss: remark and rehype plugins in next.config.ts must be string names with JSON-serializable options, because the config crosses into Rust:

ts
const withMDX = createMDX({
  options: {
    remarkPlugins: ["remark-frontmatter", "remark-gfm"],
    rehypePlugins: [
      "rehype-slug",
      ["rehype-pretty-code", { theme: { light: "github-light", dark: "github-dark-dimmed" }, keepBackground: false }],
    ],
  },
});

Anything that needs real JavaScript functions — reading time, the table of contents, markdown serialization — runs in Node inside lib/blog.ts instead, where there are no serialization constraints. The TOC ids are generated with github-slugger, the same algorithm rehype-slug uses, so the anchors in the sidebar match the headings in the rendered HTML.

Mixed locales without fake URLs

Posts are written in one to three locales, and translations lag. The honest approach:

URL stateWhat rendersCanonical
Post exists in the localeThat locale's contentItself
Post missing in the localeSource-locale content + a noticeThe source-locale URL

Fallback pages stay out of the sitemap and out of hreflang. Search engines only ever see URLs that hold real translations, and readers on a fallback URL get told exactly what they are reading.

The machine-readable surface

A blog in 2026 is read by crawlers and language models as much as by people, so each post also ships as:

  • Raw markdown at /<locale>/blog/<slug>/md, with a small provenance header.
  • Full-text RSS per locale, rendered from the same compiled MDX.
  • JSON-LD (BlogPosting + BreadcrumbList) with real dateModified values.
  • An entry in the generated llms.txt.

All of it is generated at build time from the same files. There is no second system to keep in sync — if the markdown changes, every surface changes with it on the next deploy.

What I would do differently

I considered a headless CMS and decided against it: the audience for these posts is small, the author is me, and git is a better editor workflow than any web form. If that ever changes, the zod schema in lib/schema.ts is the contract a CMS would have to satisfy — the rest of the pipeline would not need to change.

The code is in this site's repository, and the post you are reading is its test fixture.