Next.js SSG + MDX behind this blog — how it works under the hood
This blog runs on Next.js with MDX. Showing the architecture: how posts are loaded, how rendering works, how I built dual-locale, and which MDX traps I hit.

You're reading a post on kamilkaletka.dev/blog. This is the meta-post about how the blog is built. Stack: Next.js 16 with App Router, MDX via next-mdx-remote, statically generated at build time. Showing why this setup, what traps I hit, how dual-locale works.
Why not WordPress, Ghost, Hugo
WordPress = too much surface area, too many plugins, security nightmare. Pass.
Ghost = great for pure blog, but I wanted the blog to live in the same deploy as the rest of the portfolio. One host, one domain, consistent navigation.
Hugo = great SSG, but you need a Go-template ecosystem. I already have a React/TypeScript ecosystem for the portfolio; adding another doesn't make sense.
Next.js + MDX = use existing stack, drop in the blog as a /blog subsection. One Docker, one build, one deployment.
Structure
content/blog/
├── pl/ # PL posts
│ ├── claude-opus-4-7-pierwsze-wrazenia.mdx
│ └── ...
└── en/ # EN posts
├── claude-opus-4-7-pierwsze-wrazenia.mdx
└── ...
lib/blog/
├── posts.ts # loader (gray-matter + cache)
├── types.ts # PostMeta, Locale, BlogPost
├── mdx-config.ts # remark/rehype plugins
└── i18n.ts # UI strings PL/EN
app/blog/ # PL routes (default)
app/en/blog/ # EN routes (mirror)
components/blog/
├── BlogIndex.tsx # shared index body
├── PostDetail.tsx # shared detail body
├── LocaleSwitch.tsx # PL ↔ EN toggle
└── ...Loader: posts.ts
The heart is lib/blog/posts.ts. It loads all MDX, parses frontmatter, caches.
const _cache = new Map<Locale, BlogPost[]>();
function loadAll(locale: Locale): BlogPost[] {
const cached = _cache.get(locale);
if (cached) return cached;
const dir = path.join(CONTENT_ROOT, locale);
const files = fs.readdirSync(dir).filter(f => f.endsWith(".mdx"));
const posts = files.map(f => parsePost(f, locale));
// Validate unique slugs per locale
const slugs = posts.map(p => p.slug);
const dups = slugs.filter((s, i) => slugs.indexOf(s) !== i);
if (dups.length > 0) throw new Error(`Duplicate slugs: ${dups}`);
const sorted = posts.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
_cache.set(locale, sorted);
return sorted;
}Cache is a per-locale Map. In production the loader runs once at build, in dev, re-runs on each change.
Frontmatter: gray-matter
Each MDX has frontmatter:
---
title: "Post title"
description: "Short SEO description"
slug: "my-post"
date: "2026-05-20"
tags: ["tag1", "tag2"]
draft: false
---
Post content starts here.gray-matter parses YAML into an object, splits content:
const { data, content } = matter(raw);Required fields are validated at runtime, if missing, the build fails with a concrete error message.
MDX rendering: next-mdx-remote
The MDX content renders via <MDXRemote>:
import { MDXRemote } from "next-mdx-remote/rsc";
<MDXRemote
source={post.rawContent}
options={{ mdxOptions }}
/>Plugin chain (mdx-config.ts):
remark-gfm, tables, strikethrough, autolinksrehype-slug, heading anchors (<h2 id="...">)rehype-pretty-code, syntax highlighting (github-darktheme)
Code blocks with a language identifier ( ```typescript) are highlighted server-side. No runtime cost.
Dual-locale: routing
PL routes under /blog, EN under /en/blog. I don't use next-intl or middleware, simple app-directory mirroring:
app/blog/page.tsx → BlogIndex({ locale: "pl" })
app/blog/[slug]/page.tsx → PostDetail with PL post
app/en/blog/page.tsx → BlogIndex({ locale: "en" })
app/en/blog/[slug]/page.tsx → PostDetail with EN post
Loader signatures default to locale = "pl", so the rest of the portfolio (which doesn't use locale) works unchanged.
hreflang for SEO
Each post has in <head>:
<link rel="alternate" hreflang="pl-PL" href="https://kamilkaletka.dev/blog/{slug}" />
<link rel="alternate" hreflang="en-US" href="https://kamilkaletka.dev/en/blog/{slug}" />
<link rel="alternate" hreflang="x-default" href="https://kamilkaletka.dev/blog/{slug}" />Done via generateMetadata with alternates.languages:
return {
alternates: {
canonical: url,
languages: {
"pl-PL": plUrl,
...(enExists ? { "en-US": enUrl } : {}),
"x-default": plUrl,
},
},
// ...
};The EN version is optional, if it doesn't exist, no hreflang for EN.
Static generation
All posts are pre-rendered at build:
export async function generateStaticParams() {
return getAllPosts(LOCALE).map(p => ({ slug: p.slug }));
}Drafts (draft: true) are filtered in production:
const filtered = IS_PROD && !options?.includeDrafts
? posts.filter(p => !p.draft)
: posts;So drafts show in dev, are invisible in prod. Drafts with future dates (the publish queue), don't generate as routes, only after draft: false flip.
RSS, sitemap, OG images
- RSS:
app/blog/rss.xml/route.ts, generates XML from PL posts. Mirror for EN:app/en/blog/rss.xml/route.ts. - Sitemap:
app/sitemap.tsincludes both locales +alternates.languagesper post. - OG images:
app/blog/[slug]/opengraph-image.tsxusesnext/ogImageResponse, auto-generates 1200×630 PNGs for each post. EN has its own at/en/blog/[slug]/opengraph-image.tsx.
Traps
1. MDX with apostrophes in strings. "don't" in MDX content sometimes breaks the parser. Workaround: "don\\'t" or backticks.
2. Markdown tables with pipes in content. | col1 | col2 \| with pipe | needs escaping.
3. Frontmatter with non-ASCII characters. Works, but some editors save in different encoding. I force UTF-8 in VS Code per folder.
4. _cache survives hot reload. In dev after editing a post the cache held stale data. Workaround: _cache.clear() in dev mode on each request.
The Next.js + MDX stack for a blog is below the radar but it works great. Markdown on disk, full SEO, dual-locale, static build. I never rent a CMS, I never fear a vendor going down. Plus full control of layout and custom components in MDX (callouts, code copy buttons, etc.).