Skip to content
maksim zaytsev
Zápisky

12. srpna 2026 · 7 min

Ručně udělaná i18n v Next.js app routeru

Tenhle web se servíruje v angličtině, ruštině a češtině a nepoužívá žádnou i18n knihovnu. Není to principiální postoj proti next-intl — je to prostě tak, že čtyřstránkový web, jehož obsah se upravuje jako kód, nepotřebuje ICU zprávy, runtime loadery ani strom providerů. Tady je to, co skutečně potřebuje, a co mě cestou kouslo.

Routing

Každá stránka žije pod segmentem [locale]: /en/about, /ru/about, /cs/about. Seznam locales je const pole a typ Locale se z něj odvozuje, takže přidání češtiny znamenalo přidat jeden řetězec a nechat TypeScript ukázat na každé místo, kde chyběl překlad.

export const locales = ["en", "ru", "cs"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";

Požadavek bez prefixu přesměruje middleware. Pořadí priorit je cookie locale, kterou nastavuje přepínač jazyka, pak hlavička Accept-Language, pak výchozí jazyk. Přesměrování je 307 s Vary: Accept-Language, Cookie, aby CDN necachovala anglické přesměrování pro ruského návštěvníka.

const locale = getPreferredLocale(request);
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
const res = NextResponse.redirect(url, 307);
res.headers.set("Vary", "Accept-Language, Cookie");

Obsah

Překlady jsou TypeScript moduly: content/en/about.ts, content/ru/about.ts a tak dále. Každý exportuje obyčejný objekt a nevýchozí locales se ověřují proti tvaru toho anglického přes satisfies, takže chybějící klíč je chyba typu, ne prázdný řetězec v produkci. Delší texty — popisy projektů, příspěvky — jsou uvnitř těchto objektů Markdown, vykreslovaný přes react-markdown. Žádný CMS neexistuje; změna obsahu je pull request.

SEO

Každá stránka vydává canonical URL, hreflang odkazy pro všechny tři locales a x-default ukazující na cestu bez prefixu — tu, kterou řeší middleware. Protože Next slučuje metadata jen povrchově, tohle všechno vychází z jednoho helperu, buildPageMetadata(), místo objektů alternates na každé stránce, které by se tiše navzájem přepisovaly.

Tři bugy

  1. Metadata routy skončily přesměrované na 404. app/icon.tsx a app/apple-icon.tsx generují URL bez přípony jako /icon/abc123. Matcher middlewaru viděl cestu bez prefixu locale, přidal ho, a ikona tam neexistovala. Oprava je vyloučit tyhle routy z matcheru explicitně; obvyklý vzor „přeskočit cokoliv s tečkou“ je nepokryje, protože nemají příponu.
  2. Přepnutí jazyka resetovalo dark mode. Změna segmentu [locale] znovu vykreslí kořenový layout včetně <html>, a třída dark, kterou na klientovi nastavil přepínač motivu, byla pryč. Malá komponenta ThemeSync znovu aplikuje uloženou hodnotu motivu v useLayoutEffect navázaném na pathname, ještě před prvním vykreslením.
  3. Fonty bez správných subsetů. Azbuka a česká diakritika potřebují v next/font explicitní subsety cyrillic a latin-ext, a generátor Open Graph obrázků potřebuje statické TTF s těmito glyfy, protože satori neumí číst variabilní ani woff2 fonty.

Udělal bych to znovu?

Pro tenhle web ano. Celý mechanismus má kolem 150 řádků a každou jeho část lze přečíst na jeden zátah. Ve chvíli, kdy budu potřebovat pravidla plurálu, locale podle uživatele na serveru nebo překladatele, kteří nejsou vývojáři, sáhnu po knihovně — a ponechám si část „slovníky jako kód“, protože právě ta se vyplatila.