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
- Metadata routy skončily přesměrované na 404.
app/icon.tsxaapp/apple-icon.tsxgenerují 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. - Přepnutí jazyka resetovalo dark mode. Změna segmentu
[locale]znovu vykreslí kořenový layout včetně<html>, a třídadark, kterou na klientovi nastavil přepínač motivu, byla pryč. Malá komponentaThemeSyncznovu aplikuje uloženou hodnotu motivu vuseLayoutEffectnavázaném na pathname, ještě před prvním vykreslením. - Fonty bez správných subsetů. Azbuka a česká diakritika potřebují v
next/fontexplicitní subsetycyrillicalatin-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.