Hand-rolled i18n in the Next.js app router
This site is served in English, Russian and Czech and uses no i18n library. That's not a principled stance against next-intl — it's that a four-page site with content edited like code doesn't need ICU messages, runtime loaders or a provider tree. Here is what it does need, and what bit me along the way.
Routing
Every page lives under a [locale] segment: /en/about, /ru/about, /cs/about. The list of locales is a const array and the Locale type is derived from it, so adding Czech meant adding one string and letting TypeScript point at every place that needed a translation.
export const locales = ["en", "ru", "cs"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
A request without a prefix is redirected by middleware. The preference order is a locale cookie set by the language switcher, then the Accept-Language header, then the default. The redirect is a 307 with Vary: Accept-Language, Cookie, so a CDN doesn't cache the English redirect for a Russian visitor.
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");
Content
Translations are TypeScript modules: content/en/about.ts, content/ru/about.ts, and so on. Each exports a plain object, and the non-default locales are checked against the shape of the English one with satisfies, so a missing key is a type error rather than an empty string in production. Long-form text — project write-ups, posts — is Markdown inside those objects, rendered with react-markdown. There is no CMS; a content change is a pull request.
SEO
Every page emits a canonical URL, hreflang links for all three locales, and an x-default pointing at the unprefixed path — the one the middleware resolves. Because Next merges metadata shallowly, all of that comes from one helper, buildPageMetadata(), instead of per-page alternates objects that silently overwrite each other.
The three bugs
- Metadata routes got redirected to 404.
app/icon.tsxandapp/apple-icon.tsxgenerate extension-less URLs like/icon/abc123. The middleware matcher saw a path without a locale prefix, added one, and the icon didn't exist there. The fix is to exclude those routes from the matcher explicitly; the usual “skip anything with a dot” pattern doesn't cover them because they have no extension. - Switching language reset dark mode. Changing the
[locale]segment re-renders the root layout, including<html>, and thedarkclass the theme toggle had set on the client was gone. A smallThemeSynccomponent re-applies the persisted theme in auseLayoutEffectkeyed on the pathname, before the first paint. - Fonts without the right subsets. Cyrillic and Czech diacritics need explicit
cyrillicandlatin-extsubsets innext/font, and the Open Graph image generator needs static TTFs with those glyphs, since satori can't read variable or woff2 fonts.
Would I do it again?
For this site, yes. The whole mechanism is about 150 lines, and every part of it is readable in one sitting. The moment I needed pluralisation rules, per-user locale on the server, or translators who aren't developers, I'd reach for a library — and keep the dictionaries-as-code part, which is the bit that has paid for itself.