Front-End Application Internationalization with i18next


Front-End Application Internationalization with i18next
Modern software platforms often make use of multiple front-end applications. Managing the display of UI strings across these applications can be complex and introduce opportunities for parts of your application to display the wrong or inaccurate content. This is all the more important as a product's reach expands into international markets and the need to support languages and locales increases.
When managing many single-page applications, often created and maintained by different teams, we want to ensure they always know what language they are displaying and that the correct content is shown to users.
In this post, we discuss how we use i18next at Toast to manage our UI string translation requirements. We enable a consistent approach to setting up source files, ensure the displayed language is consistent, and assist with tasks such as measuring and linting best practices in this area.
Problem: Displaying UI Stings consistently across many applications
The problem we set out to solve was how to best manage the loading and display of UI strings across many single-page applications (SPAs).
The SPAs also include components from an in-house component library, many of which have their own strings. When a user loads a view, they may be seeing the rendering of multiple SPAs along with many components and it's important when supporting multiple languages that every SPA and component displays UI strings in the correct locale.
Determining locale
A locale is a two-part code made up of language and variant. For example en-US would be English, United States. Alternatively, es-US would represent Spanish with a US variant. We use these to decide which language and region of language drives the UI content across applications.
There are many ways to determine which language or locale to display in an application. It can be derived from a user's browser settings, obtained from a user-level setting in the application, or even driven by a restaurant's country and language.
While it can be important to have flexibility in certain situations, leaving each SPA to take on the responsibility of determining which locale is needed can result in inconsistency and possibly incorrectly-translated UI strings.
A single source of truth
To approach this, we introduced tooling to act as a central store of localized strings in the running application.
This would make use of a single instance of i18next. The instance is set up once at the root of our applications, where the chosen locale is set. SPAs can then load their UI strings as they mount.
This creates a single source of truth for both the current locale being displayed, as well as a single place where all strings are loaded. Component libraries also load their components' strings (by locale) into i18next.
As a result, all locale settings and the store of strings used across all front-end applications are managed in one place.
What is i18next?
i18next, created by the founders of locize, describes itself as an "internationalization-framework written in and for JavaScript". It's a well established tool that helps store and manage strings for use in applications and licensed under MIT open source license. It includes methods for managing the current locale, adding resources (source files of strings as key/value pairs) and includes methods for displaying translated UI strings.
It also brings a rich community of plugins and extensions, supporting more complex React use-cases as well as support for all sorts of source file formats and platforms.
No locale needed
By making use of i18next as a single source of truth, we can remove the need for individual SPAs to specify locale either when rendering components from our shared component library, or when carrying out formatting tasks such as displaying dates.
For example, translations can be handled by initially loading a SPAs source file, and then in components using a t method to translate the output:
import { t } from 'i18next'
export const MyComponent = () => {
return <>{t('hello-world')}</>
}
In the above the t function takes a key and i18next decides which language for "Hello world" gets displayed. As the locale is set once in the shared instance, the SPA doesn't need to be concerned with determining which locale is needed.
This also applies to the use of formatting methods or components from our shared component library.
For example, instead of displaying dates like this:
format(date, Formats.date.short, locale) // 09/14/24
<Shared Component locale={locale} />
We can drop the locale part and simplify our code, reducing the chance of errors:
format(date, Formats.date.short) // 09/14/24
<Shared Component />
Setting up i18next to support multiple SPAs
To support the many SPAs using translations, we set up a single source of i18next within our component library. It was set up to re-export i18next itself, but also some convenience methods (such as avoiding throwing errors in situations where i18next might not have been initialized yet):
import i18next from 'i18next'
import { Locale } from '@local/types' // A type representing a union of the locales we support
export { i18next } // Re-export i18next to ensure everyone's using the same version
export const initI18Next = (language?: Locale) => {
return i18next.init({
lng: language || 'en-US',
fallbackLng: 'en-US',
resources: {}
})
}
export const changeLanguage = (language: Locale) => {
if (!i18next.language) {
initI18Next()
}
i18next.changeLanguage(language)
}
export const hasResourceBundle: typeof i18next.hasResourceBundle = (
...props
) => {
if (!i18next.language) {
initI18Next()
}
return i18next.hasResourceBundle(...props)
}
export const addResources: typeof i18next.addResources = (
lng,
ns,
resources
) => {
if (!i18next.language) {
initI18Next()
}
return i18next.addResources(lng, ns, resources)
}
We make these available as translation-utilities within our shared component library so that each SPA can import this as needed when managing translations.
The above code does not change the way i18next works. It offers a default init method that can be run should a resource be added without first initializing i18next. Each of the wrapped methods makes use of this but otherwise we're just wrapping the i18next methods.
The main concern here is ensuring the same specific version and instance of i18next is used everywhere. To address this need, we need to adjust webpack's configuration.
Ensuring i18next is external
If applications loaded their own version of i18next, the locale won't be set or there could be errors when referencing string keys. To get around this, we extended our shared webpack configuration to tell webpack not to bundle i18next in any application builds.
This involved setting the externals array in our webpack config to include "i18next". However, without any other changes this will mean that i18next is unavailable. We set up a global dependency, alongside React, to load i18next first.
This results in webpack trusting that we're taking care of loading i18next outside of the built SPAs, but offers the benefit of knowing that there will only be one version of i18next running at any time.+
Handling translations within SPAs
With a single i18next instance in place, SPAs need to put their UI strings into i18next before they can be displayed. This involves setting up source files, and then loading these source files into i18next at runtime.
Setting up source files
A pattern we use across SPAs is to have a packages directory that includes local packages. Each SPA is configured to make these available for imports as @local/package-name (where "local" maps to /packages). This saves having messy imports with strings of ../../../ everywhere.
To prepare a SPA for translations, we add a translations package and within it's src folder, a folder for containing locales:
/src/locales/en-US.json
/src/locales/es-US.json
...etc
These files contain key/value pairs representing UI strings, such as:
{
"hello-world: "Hello world"
}
As a practice we tend to focus on creating the en-US strings source files while developing. The process for obtaining translated source files for other languages is a process we'll skip over here, as this may depend on your specific needs and setup. However with the files in place, we then need to load them into i18next.
Loading source files into i18next
When an application is mounted, it needs to load into i18next the correct UI string content in the right language.
We use the addResource method from the translation-utilities above, alongside dynamic imports handled by webpack:
export async function loadTranslations() {
addResources('en-US', spaName, defaultStrings)
const locale = i18next.language
try {
const bundle = await import(`./locales/${locale}.json`)
addResources(locale, spaName, bundle.default)
} catch {
addResources(locale, spaName, defaultStrings)
}
}
This loads our default en-US strings so that any missing translated strings will fall back to English. It then uses an await import to load ${locale}.json and add the resource file. It uses a try/catch and falls back on loading the defaults should something go wrong.
A part of the consideration for this approach was to ensure that we didn't bundle every resource file as doing so could add to the overall bundle size and slow down the processing and rendering times of the application. Instead, this import will prompt the browser to load the file as needed, and then put it into i18next.
The spaName value here is a string representing the name of the SPA. This is a namespace that ensures that the strings for this SPA won't clash with any other SPAs. This avoids the possibility of keys clashing with each other.
To make use of loadTranslations, we add this method to the bootstrap methods used when mounting the component. This implementation will depend on how your front-end applications are defined.
You can also run loadTranslations when running in other environments such as Storybook.
Displaying translated UI strings
After loading the strings, we can make use of i18next's t function to render the expected translated strings.
When using TypeScript we can make use of the namespaced t function exported from translation-utilities:
import { t } from 'i18next'
export const MyComponent = () => {
return <>{t('hello-world')}</>
}
Overriding locale settings for specific use-cases
A big part of developing front-end applications is using tools such as Storybook to mock up UI and test it before shipping. To set up this process we initially set up i18next to ensure the relevant strings are added:
import {
initI18Next,
addResources
} from 'our-component-library/translation-utilities'
import defaultStringsEnUS from '@local/translations/src/locales/en-US.json'
const locale = 'en-US' // Change this if different default needed
const name = 'spa-name'
const i18next = initI18Next(locale)
addResources(locale, name, defaultStringsEnUS)
With this we then add a decorator to present a list of locales so we can change locales while viewing the work in Storybook:
.storybook/preview.tsx
import { Preview, StoryFn, StoryContext } from '@storybook/react'
import { changeLanguage } from 'our-component-library/translation-utilities'
const withChangeLanguage = (story: StoryFn, context: StoryContext) => {
// ChangeLanguage will also initialise i18next if it hasn't been initialised yet
changeLanguage(context.globals.locale)
return <>{story(context.initialArgs, context)}</>
}
const preview: Preview = {
//... other config
locale: {
description: 'Internationalization locale',
defaultValue: 'en-US',
toolbar: {
title: 'Locale',
icon: 'globe',
items: ['en-US', 'es-US', etc ], // Any supported locales
dynamicTitle: true
}
}
},
decorators: [withChangeLanguage]
}
With this in place we can change languages using a dropdown in Storybook, and more easily test if our UI works with all supported languages.
Going further - overriding and pseudo-localization
With a single instance of i18next in place containing all your applications' locale source strings, there are all sorts of interesting things we can do with this. We can apply overrides in testing environments that dynamically adjust the strings for pseudo-localization.
We could implement checks that look for strings that aren't translated, perhaps by adding special characters or adjusting the translated strings so that untranslated strings are easier to locate.
This approach opens many opportunities to introduce useful layers of metrics and quality checks as needed.
Conclusion
Using i18next as a single source for locale settings as well as a store for strings, we can more easily manage the consistent display of translated UI content across large-scale front-end applications. As well as ensuring a consistent language between applications and shared component libraries, this approach opens the doors to more advanced testing and quality-control metrics, while supporting individual teams in maintaining their applications independently.
____________________________
This content is for informational purposes only and not as a binding commitment. Please do not rely on this information in making any purchasing or investment decisions. The development, release and timing of any products, features or functionality remain at the sole discretion of Toast, and are subject to change. Toast assumes no obligation to update any forward-looking statements contained in this document as a result of new information, future events or otherwise. Because roadmap items can change at any time, make your purchasing decisions based on currently available goods, services, and technology. Toast does not warrant the accuracy or completeness of any information, text, graphics, links, or other items contained within this content. Toast does not guarantee you will achieve any specific results if you follow any advice herein. It may be advisable for you to consult with a professional such as a lawyer, accountant, or business advisor for advice specific to your situation.