Next.js SSG + MDX pod tym blogiem — jak to działa pod spodem
Ten blog jest postawiony na Next.js z MDX. Pokazuję architekturę: jak posty są ładowane, jak działa rendering, jak zrobiłem dual-locale, i jakie pułapki MDX miałem po drodze.

Czytasz post na kamilkaletka.dev/blog. To jest meta-post o tym jak ten blog jest zbudowany. Stack: Next.js 16 z App Router, MDX z next-mdx-remote, statycznie generowane przy buildzie. Pokazuję dlaczego ten setup, jakie pułapki napotkałem, jak działa dual-locale.
Dlaczego nie WordPress, Ghost, Hugo
WordPress = za duża powierzchnia, za dużo plug'inów, security nightmare. Nie chcę.
Ghost = świetny dla pure-blog, ale chciałem żeby blog żył pod tym samym deployem co reszta portfolio. Jeden host, jedna domain, spójna nawigacja.
Hugo = świetny SSG, ale trzeba ekosystemu Go-templates. Już mam ekosystem React/TypeScript dla portfolio, dodawanie kolejnego nie ma sensu.
Next.js + MDX = use existing stack, dorzucić blog jako podsekcję /blog. Jeden Docker, jeden build, jeden deployment.
Struktura
content/blog/
├── pl/ # PL posty
│ ├── claude-opus-4-7-pierwsze-wrazenia.mdx
│ └── ...
└── en/ # EN posty
├── 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
Sercem jest lib/blog/posts.ts. Wczytuje wszystkie MDX-y, parsuje frontmatter, cache'uje.
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 jest per-locale Map. W production loader uruchamia się raz przy buildzie, w dev, re-run po każdej zmianie.
Frontmatter: gray-matter
Każdy MDX ma frontmatter:
---
title: "Tytuł posta"
description: "Krótki opis dla SEO"
slug: "moj-post"
date: "2026-05-20"
tags: ["tag1", "tag2"]
draft: false
---
Treść posta zaczyna się tutaj.gray-matter parsuje YAML do obiektu, oddziela treść:
const { data, content } = matter(raw);Wymagane pola sprawdzane w runtime, jeśli brakuje, build fail z konkretnym error message.
MDX rendering: next-mdx-remote
Treść MDX renderuje się przez <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, anchory dla nagłówków (<h2 id="...">)rehype-pretty-code, syntax highlighting (github-darktheme)
Code blocks z language identifier ( ```typescript) są highlighted server-side. Brak runtime cost.
Dual-locale: routing
PL routes pod /blog, EN pod /en/blog. Nie używam next-intl ani middleware, proste mirroring app directories:
app/blog/page.tsx → BlogIndex({ locale: "pl" })
app/blog/[slug]/page.tsx → PostDetail z PL post
app/en/blog/page.tsx → BlogIndex({ locale: "en" })
app/en/blog/[slug]/page.tsx → PostDetail z EN post
Sygnatury loadera mają default locale = "pl", więc reszta portfolio (która nie używa locale) działa bez zmian.
hreflang dla SEO
Każdy post ma w <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}" />Robione przez generateMetadata z alternates.languages:
return {
alternates: {
canonical: url,
languages: {
"pl-PL": plUrl,
...(enExists ? { "en-US": enUrl } : {}),
"x-default": plUrl,
},
},
// ...
};EN wersja jest opcjonalna, jeśli nie istnieje, nie pokazujemy hreflang dla EN.
Static generation
Wszystkie posty są pre-renderowane przy buildzie:
export async function generateStaticParams() {
return getAllPosts(LOCALE).map(p => ({ slug: p.slug }));
}Drafty (draft: true) są filtrowane w production:
const filtered = IS_PROD && !options?.includeDrafts
? posts.filter(p => !p.draft)
: posts;Czyli drafty widać w dev, w produkcji są niewidzialne. Dla draftów z przyszłą datą (kolejka do publikacji), nie generują się jako route, dopiero po flip'ie draft: false.
RSS, sitemap, OG images
- RSS:
app/blog/rss.xml/route.ts, generuje XML z postów PL. Mirror dla EN:app/en/blog/rss.xml/route.ts. - Sitemap:
app/sitemap.tszawiera oba locale +alternates.languagesper post. - OG images:
app/blog/[slug]/opengraph-image.tsxużywanext/ogImageResponse, auto-generuje 1200x630 PNG dla każdego posta. EN ma własne pod/en/blog/[slug]/opengraph-image.tsx.
Pułapki
1. MDX z apostrofami w stringach. "don't" w treści MDX czasem psuje parser. Workaround: "don\\'t" lub backticki.
2. Tabele markdown z pipe'ami w treści. | col1 | col2 \| with pipe | trzeba escape'ować.
3. Frontmatter z polskimi znakami. Działa, ale niektóre edytory zapisują w innym encodingu. Wymuszam UTF-8 w VS Code per-folder.
4. Cache _cache przeżywa hot reload. W dev po edycji posta cache trzymał stare. Workaround: _cache.clear() w dev mode na każdym request.
Stack Next.js + MDX dla bloga jest poniżej radaru ale działa świetnie. Markdown na dysku, full SEO, dual-locale, statyczny build. Nigdzie nie wynajmuję CMS-u, nigdy nie boję się że dostawca padnie. Plus mam pełną kontrolę nad layoutem i custom componentami w MDX (callouts, code copy buttons, etc.).