Skip to content
← All decisions

Rolling i18n by hand instead of installing it

accepted

Context

Two locales, Persian and English, static copy, no pluralisation, no gendered forms, no rich-text interpolation. next-intl is the default answer and it is a good library.

It also owns the routing layer — its own <Link>, its own useRouter, its own redirect, its own proxy configuration. On a portfolio whose entire argument is that I build framework-level infrastructure, outsourcing the routing layer is the one dependency that undercuts the claim it is supposed to support.

Options considered

next-intl. ICU MessageFormat, mature, ~2KB client runtime. Its real value is plurals and rich text. Persian's plural system is trivial in CLDR terms and English has one/other, so the ICU argument is weak at this scale. It also broke on the Next 16 middlewareproxy rename, which is a second argument against inheriting someone else's coupling to a moving API.

JSON dictionaries with a lookup helper. No type safety worth the name — JSON gives a structural type but no cross-file constraint, so a key present in English and missing in Persian is a runtime undefined.

TypeScript dictionary modules, English as the schema. English is authored first and is the contract. Persian is an implementation of it.

Decision

// en.ts — the schema
export const en = { nav: { work: 'Work' } } as const

// fa.ts — the implementation
export const fa: Dictionary = { nav: { work: 'نمونه‌کار' } }

A key missing from Persian is a compile error, not a runtime hole. A typo'd extra key is also a compile error, because annotating a direct object literal fires the excess-property check. That is the whole type-safety story: no library, no codegen, no runtime validation.

Locale reaches Server Components through next/root-params, new in Next 16.3, which removes prop drilling entirely and composes with 'use cache' — only the root params a cached function actually reads become part of its cache key.

Tradeoffs accepted

  • next/root-params is Server-Component-only. Not available in Client Components, Server Actions, Route Handlers, or unstable_cache. Every function in those four places takes locale as an explicit first argument, with a comment saying why. That is friction I accepted knowingly.
  • Client Components get strings by prop, not by hook. They receive narrow typed slices (t: Dictionary['playground']), never the whole dictionary — whatever crosses the boundary is serialised into the RSC flight payload of every page that renders it, and flight data is invisible to my bundle-size gate. The library's context approach is genuinely more ergonomic here.
  • No ICU. The day I need {count, plural, ...} I will have to write it or install it.

Result

About thirty lines. Missing translations cannot reach production because they do not compile. Neither dictionary reaches the client — a dynamic import() keeps the unused locale out of the bundle, and because it is only ever awaited on the server, only rendered strings are sent.

What I would do differently

The obvious typing is wrong and I shipped it before the compiler caught me. I wrote:

export type Dictionary = typeof en

which reads correctly and is broken. as const makes every English string a literal type, so this demands that the Persian file contain the English strings verbatim — seventeen type errors, one per translated key. The fix is to map the shape and widen the leaves:

type Widen<T> = T extends string ? string : { [K in keyof T]: Widen<T[K]> }
export type Dictionary = Widen<typeof en>

The key structure stays the contract; the values stop being one. I would reach for that pattern immediately next time instead of discovering it at the first build.

I would also be clearer about when this decision is wrong. For two locales of static copy, hand-rolling is right. At five locales, with translators working in a TMS, with plurals and dates embedded in sentences, next-intl is the correct answer and I would install it without arguing. The point is not that libraries are bad — it is that this particular library's cost, on this particular project, was paid in the exact currency the project is about.