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.
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:
- Metadata is read in Node with
gray-matter. This powers the index, RSS, sitemap, JSON-LD, andgenerateMetadata. It never goes through the MDX compiler. - Rendering is done by
@next/mdxthrough a dynamic import in the article page. The compiler never sees the frontmatter —remark-frontmatterstrips 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:
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 state | What renders | Canonical |
|---|---|---|
| Post exists in the locale | That locale's content | Itself |
| Post missing in the locale | Source-locale content + a notice | The 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 realdateModifiedvalues. - 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.