Blog
ENPL

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.

·5 min read
Next.js SSG + MDX behind this blog — how it works under the hood

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, autolinks
  • rehype-slug, heading anchors (<h2 id="...">)
  • rehype-pretty-code, syntax highlighting (github-dark theme)

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.ts includes both locales + alternates.languages per post.
  • OG images: app/blog/[slug]/opengraph-image.tsx uses next/og ImageResponse, 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.).