Building a Bilingual Site Without an i18n Library
Why I shipped my own dictionary layer instead of reaching for next-intl
Every tutorial tells you to install an i18n library the moment you need a second language. I tried that, ripped it out two days later, and wrote about sixty lines of my own instead. This is what that decision actually bought me, and the parts I would not repeat.
01What I Actually Needed
My requirement list was short. Two languages, English and Arabic. Content that lives in version control, not a CMS. Type safety, so a missing key is a build error and not a blank space in production. And server rendering, because a portfolio that ships a translation bundle to the browser for text that never changes is wasting everyone's bandwidth. Most i18n libraries are built for a much harder problem than mine: dozens of locales, translator handoff, plural rules across languages with six plural forms, runtime locale switching in a single-page app. I had none of that. What I got in exchange for those features was a provider I had to wire in twice, a namespace concept I did not need, and TypeScript types that were generated rather than inferred, which meant a build step between me and an autocomplete suggestion. The mismatch was not the library's fault. I was paying for a general solution to a specific problem, and the specific problem fit in a single file.
02The Shape of the Solution
The whole system is one JSON file per language and one module that loads them. Each dictionary is imported dynamically, so only the active language ends up in the server bundle. The English file is the source of truth for types: I infer the dictionary type from the return value of its import, which means every key I add to English immediately exists in the type system with no code generation step. If Arabic drifts, TypeScript does not catch it directly, so I added a small parity check that walks both files and compares flattened key paths. Server components call an async function that returns the whole dictionary and destructure the section they need. Client components read the same object through a context provider that the layout populates once. There is no hook that fetches, no suspense boundary, no loading state. The translations are just props that happen to arrive as context. The most useful piece turned out to be the smallest: a formatting helper that swaps double-brace placeholders for values, so a template string can carry a name or a count without string concatenation scattered through components.
03Routing and the Locale Segment
The language lives in the URL as the first path segment. Every page sits under a dynamic segment, and middleware handles anyone who arrives without one. The resolution order matters more than it looks: an explicit cookie wins first, because a visitor who deliberately switched languages should not be overridden on their next visit. Failing that, the Accept-Language header is negotiated against the supported list. English is the fallback. I redirect with a permanent status rather than rewriting, because I want the canonical URL visible in the address bar and in whatever the visitor copies into a message. A rewrite would have left the bare path in the bar and quietly served localized content from it, which creates two URLs for one page and splits their search ranking. The subtlety I got wrong the first time was the search string. Redirecting to the prefixed path without carrying query parameters forward silently dropped filters and search terms whenever someone shared a link. One line to fix, twenty minutes to notice.
04What I Would Not Repeat
This approach has a ceiling and I can see it from here. The moment a third language arrives, hand-maintaining parallel JSON files stops being pleasant, and the parity script becomes a real build gate rather than a convenience. If a non-developer ever needs to edit copy, JSON in a repository is a hostile interface and I would move the content behind something with a form. Plurals are the honest weak spot. My formatter does substitution and nothing else, so anything that changes shape with a count needs a separate key per case, and Arabic has more cases than English does. I have avoided the problem rather than solved it, which is fine for a portfolio and would not be fine for a product. What I would keep, in any project of this size, is the principle underneath. Reach for the library when the problem is genuinely the library's problem. Two languages of static marketing copy is not that problem, and the sixty lines have needed almost no maintenance since the week I wrote them.
Takeaways
Rolling your own i18n is bad advice as a general rule and was the right call here. The deciding question was never whether the library was good, it was whether my problem was the shape the library was built for. Write down what you actually need before you install anything, and the answer is usually obvious.