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 middleware → proxy 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-paramsis Server-Component-only. Not available in Client Components, Server Actions, Route Handlers, orunstable_cache. Every function in those four places takeslocaleas 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.