Skip to content
← Writing

· 7 min

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

  1. Metadata routes got redirected to 404. At the time, app/icon.tsx and app/apple-icon.tsx generated 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 was to exclude those routes from the matcher explicitly; the usual “skip anything with a dot” pattern didn't cover them because they had no extension. The icons have since become static files with real extensions, and the exclusion is gone.
  2. Switching language reset dark mode. Changing the [locale] segment re-renders the root layout, including <html>, and the dark class the theme toggle had set on the client was gone. A small ThemeSync component re-applies the persisted theme in a useLayoutEffect keyed on the pathname, before the first paint.
  3. Fonts without the right subsets. Cyrillic and Czech diacritics needed explicit cyrillic and latin-ext subsets in next/font, and the Open Graph image generator needs static TTFs with those glyphs, since satori can't read variable or woff2 fonts. The fonts are now self-hosted and split by unicode-range, so each locale preloads only the subsets its own text uses.

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.