
Building an internationalization system from scratch in NextJS (i18n)
Introduction
I consider myself a code miner. There may be huge libraries out there, but that doesn't stop me from keeping on digging. A couple of years ago, I delved into frontend development and began building my own libraries. Among all the headaches, one of them was internationalization.
In this article, I want to explain how I built, from scratch, an internationalization (i18n) system in Next.js, avoiding external dependencies and maintaining full control over dictionary loading and key validation. If you're interested in understanding the architecture and real problems I had to solve (with code), keep reading.
I want to make it clear that this is a particular solution: there are surely libraries out there with much more maturity and functionality than mine.
Note: This article is based on Next.js 13 with the App Router, which allows JSON to be dynamically imported on the server. In earlier versions, this technique may not work or may require adjustments.
First approach: internationalization with static dictionaries in Next.js
Here's my first iteration: wanting to go my own way, I started building a simple dictionary. With this library, I aimed to have control over loading and to avoid using helper functions to translate fixed strings in the code.
import "server-only"; export type DictData = { [x: string]: string | string[] }; export type Dict = { [x: string]: Promise<DictData> }; export type Dicts = { [x: string]: () => Dict; }; const dictionaries: Dicts = { en: () => ({ common: import("../languages/en/common.json").then((module) => module.default), sassprependviawebpack: import("../articles/en/sassprependviawebpack.json").then( (module) => module.default ), }), es: () => ({ common: import("../languages/es/common.json").then((module) => module.default), sassprependviawebpack: import("../articles/es/sassprependviawebpack.json").then( (module) => module.default ), }), }; export const getDictionary = async (locale: string) => dictionaries[locale]?.();
The `getDictionary` function returns an object where each key points to a promise that resolves to the JSON corresponding to that section of the dictionary (for example, "common" or "sassprependviawebpack"). That's why, to access the data, we need to use `await`.
On the other hand, the `import` function you see here is the standard dynamic import from `ESM / ESModules`, which allows modules to be loaded dynamically, making it useful for splitting code and loading only what's needed. In this case, we're importing the JSON files that contain the translations.
const fullDict = await getDictionary(lang); const common = await fullDict.common; console.log(common); // Ejemplo de Salida simplificada // { welcome: "Bienvenido", goodbye: "Adiós" }
Simple, right? With this, I just call `getDictionary` and it returns all the common phrases and articles.
Problems detected: scalability and organization
One of the first problems detected was scalability. What if I want to add 15 more languages? I'd have to replicate the code for each language, increasing technical debt and the risk of inconsistencies.
Not only that: in addition to my articles, I also wanted to include my projects with all their information, but how do I differentiate them from articles? I need the ability to include translations for different types of content, and it's not clear how to organize them properly.
Second iteration: modular structure and dynamic loader
With this, I proposed a separation of responsibilities: each object would be what it needs to be, whether it's a common dictionary, articles, or projects.
Alongside this, a `loader` that loads with a specific configuration and that the dictionaries adhere to so they can all be loaded together.
The new code looks like this:
import "server-only"; import { i18n } from "../../../../../i18n.config"; export type DictData = { [x: string]: string | string[] }; export type Dict = { [x: string ]: Promise<DictData> }; export type Dicts = { [x: string ]: () => Dict; }; export type Path = { folder: string; names: string[]; }; const dictionaries: Dicts = {}; const commonDictionaries: Path = { folder: "languages", names: ["common"], }; const articles: Path = { folder: "articles", names: ["sassprependviawebpack", "anotherarticle"], }; const projects: Path = { folder: 'projects', names: ['codingflavour', 'portfolio'], }; const fullDictionaries = [commonDictionaries, articles, projects]; for (let lang of i18n.locales) { let art: Dict = {}; for (let dictionaries of fullDictionaries) { for (let name of dictionaries.names) { art[name] = import(`../${dictionaries.folder}/${lang}/${name}.json`).then((module) => module.default); } } dictionaries[lang] = () => ({ ...art, }); } export { articles, projects }; export const getDictionary = async (locale: string) => dictionaries[locale]?.();
With an example of its usage:
const fullDict = await getDictionary(lang); const common = await fullDict.common; const article = await fullDict.sassprependviawebpack; console.log(common.welcome); // "Welcome" console.log(article.header); // "Sass Prepend via Webpack"
Problem of scalability and responsibility identification solved.
Next steps: synchronization and validation
As you can see, we still have some problems, some more serious than others.
- Dictionary loading is asynchronous and we use a Singleton.
- In multi-request systems, the object could be overwritten by concurrent requests, causing issues with already resolved promises.
- There is no type of key validation.
- All components trying to use a specific dictionary need to await it, even if it's not always necessary.
const fullDict = await getDictionary(lang); const common = await fullDict.common;
All this and more in our next article: "Building an internationalization system from scratch in NextJS: Synchronization"