How to localize a NextJS app

Some background
Briefly, internationalization (i18n, 18 is because there are 18 letters between 'i' and 'n') is the process of preparing your app ready to be localized, for example, changing a string in the code to a translation key (e.g. 'Failed to sign in. Please check your password.' -> 'sign_in_modal_password_error_alert') so that the app can grab different translations according to its current locale with this same key and localization (l9n, same story) is the process of fulfilling all the translation in the app, including translating all texts behind their keys into different languages, adjusting specific UI issues comparing with the original (English) version UI (e.g. same sentence in Russian can be much longer than in Chinese, thus the related UI should be modified or something like button text overflows will be seen (quite often)), dealing with local policy , marketing or religion (you name it) adaptations (e.g. color red might not be a proper color in middle east area and certain marketing slogans might be too aggressive) and so on. Localization is the work with many tedious tasks.
So very briefly, the goal of localizing an app well is to translate all its texts properly and deal well with any potential (and tedious) issues like the ons mentioned above. In order to showcase of how to do this, let's localize a NextJS app with 'i18next'. You can also use 'next-intl' or 'react-intl'. There are many comparison articles about these three andhere's the 'npm' downloading statistics of them.
Specific localization instruction
Here're the detailed steps (all workable code can be found from the example repo at the very end of this article and assume you already know the basics of 'NextJS'):
- Install dependencies
Install 'react-i18next', 'i18next', 'i18next-browser-languagedetector', 'i18next-resources-to-backend' and ''accept-language'' by yarn add react-i18next i18next i18next-browser-languagedetector i18next-resources-to-backend accept-language.
- Create 'i18next' client(s)
Create an 'i18next' client with the app's i18n configs (e.g. *appLocales*, *cookieName*, etc.). 'react-i18next' is used to have 'react' specific settings, 'i18next-browser-languagedetector' to detect browser language, 'i18next-resources-to-backend' to load translations and 'accept-language' to work with request Accept-Language header.
One special feature of 'NextJS' is that it introduces many server ideas to the frontend territory (client side), for example, contents of a web app that do not change (often) like an instruction or a post can be rendered during build time on the server and just sent to client, and things normally require (much) more interactions between the app and the user are still fulfilled by the client like user signing up or commenting a post. You can definitely do all these server things on the client (it's said Material UI just changes many things to client under the hood) but the (new) server ways have their different benefits.
In order to use 'i18next' on server and on client separately, you can have both a server getT helper function and a client useT (hooks are client only and a typical example isSWR). Give namespace (very briefly, name of a translation file) or an array of namespaces and special options if needed to the helper function and you can get the translation function t (you can name it any other names) that can be used to convert translation keys in the code to their translations on the app UI. Both getT and userT have this same implementation logic and some differences are: 1) getT gets app locale from headers and userT just from the client code, an activeLocale state hook; 2) getT gets translation function t from 'i18next' and userT from useTranslation hook of 'react-i18next'. Additionally, these two helper functions both have logics of aligning 'i18next' 's resolved language, which is the single source of truth of the app's current language.
- Add locale to path with 'Next.js' middleware
The most common logic of letting 'i18next' client know which locale to use is through path or URL, e.g. https://i-earvin.vercel.app/en-US/posts or https://i-earvin.vercel.app/zh-CN/posts (better to use 'language code + region code' even there's only one ja-JP compared with en-US and en-UK to avoid potential issues, which is also a very good localization practice of designing things as more detailed as you can at the very beginning or you might need (huge) changes in the future).
‘NextJS’ middleware provides the solution to fulfill this: if locale is in path, just set new request header x-i18next-current-locale with the path locale; if locale is not in path, get locale from request's cookie i18next-locale or header Accept-Language (or even referer path, but this approach has certain drawbacks like the user navigates from a German site (only language learning purpose) though this user's most used language is actually English) or default locale and redirect).
But normally if the app also has API code, its middleware should avoid adding locale to these API links and things like static and public resources do not need links with locales as well (but if you want to localize your static assets, then adding locales (different folders of different translated assets) just solves this issue, which might cost a lot more time, energy and/or money though and which is also why try not to put texts on images).
Since with the above logics set up and the app's paths will always have the locale, we need a new upper most layer of the app router, e.g. app/(routes)/[locale]/posts/page.tsx or app/(routes)/[locale]/projects/page.tsx.
- How to change app locale
Users can definitely manually modify the URLs to change the app locale, which is not ideal, so you might need a 'languageChanger' component (normally on the app's upper right corner). By selecting a specific locale with 'languageChanger', the i18next-locale cookie (used to know users' previous locale choices and help middleware do redirects) is changed to this locale, the app router redirects users to the new URL with this locale and the current page gets corespondent localized contents.
- Where to store translation files and how to manage them
Static translation files can be stored in public/locales/[locale]/[namespace].json, e.g. public/locales/ja-JP/module_contact_me_modal.json. Here namespace is the name of a specific translation file. There are many different ways of managing these files, for example, shared translations of the whole app are in global.json for texts like submit or cancel but if you have a special type of cancel of ContactMeModal module or just Modal shared component, you need the translations in specific module_contact_me_modal.json or component_modal.json.
Managing translations well is really the key of a good localization work. And a common practice is to save static translations on a CMS platform (content management system, 'Contentful' can work as a translation CMS platform as well if you want) and fetch them using SSG (server side generation) instead of saving and managing them locally because these CMS platforms have much more powerful, useful and convenient ways of doing translation management and other related localization works, e.g. giving screenshots to certain texts making it easier for translators to translate these texts or even directly connecting to CAT (computer assisted translation, not this cat -------------------------------------------------------------- 🐾 or Caterpillar) or TMS (translation management system) for continuous software localization.
If a user change to ja-JP locale and there's no translations yet, 'i18next' will render default locale (English or so), but on the opposite side if there're deprecated translations (translations are not used anymore with the app development changes) and they are not cleaned up properly and regularly, resources will be wasted and future translation management works might be super messy.
- Implement 'i18next' in code
After having all the above infrastructures, use t function from useT (client) or getT (server) with namespace(s) then strings in code can be just converted to needed translations according to different locales, e.g. <Button>{t('contact_me_button_submit_text')}</Button>. Specific usages of 'i18next' like how to deal with variables in strings or plurals (really a big topic) or time and currency can all be found in theirofficial doc.
When developing after your app has localization capabilities, always remember to use translation keys instead of hard-coded strings in code or it can be a lot of work getting back and find all texts that need to be translated everywhere in the codebase. I think this is a different ideology of programing and at least consider localization after main features are finished for each PR.
Most important
The example repo ishere.