Blog
PLEN

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.

·5 min read
Next.js SSG + MDX pod tym blogiem — jak to działa pod spodem

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, autolinks
  • rehype-slug, anchory dla nagłówków (<h2 id="...">)
  • rehype-pretty-code, syntax highlighting (github-dark theme)

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.ts zawiera oba locale + alternates.languages per post.
  • OG images: app/blog/[slug]/opengraph-image.tsx używa next/og ImageResponse, 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.).