Anything you can do to 'widen the funnel' of potential users for your mobile application, the better. Simply by serving your application in the user's native language, you're making it easier for them to use and more likely to convert. Implementing proper internationalisation (i18n) becomes crucial for maximising your success.
Easily expandable locale support
When building internationalised apps at Add Jam, one of our key objectives is to make adding new languages as frictionless as possible with minimum code changes. Ideally, we want to be able to add a new language by simply dropping a new translation file into our project without having to modify any code.
When internationalising a React Native app we want to:
- Reduce the chance of errors when adding new languages
- Make it easier for non-developers to contribute translations
- Allow for dynamic loading of languages based on user preferences
- Scale well as your app grows to support more languages
To achieve this, we'll use a dynamic import approach that automatically discovers and loads all available translation files. Let's see how we implement this in our React Native apps that are in production.
Setting up the foundation
Our i18n setup uses two primary dependencies:
# for managing translations
npm install i18n-js
# for detecting device language settings
npm install react-native-localize
Important Note: The dynamic import approach using require.context
requires a special Metro configuration. You'll need to add unstable_allowRequireContext: true
to your metro.config.js
file:
module.exports = {
transformer: {
unstable_allowRequireContext: true,
// other transformer options...
},
// other metro config options...
}
As the "unstable_" prefix suggests, this is an experimental feature in Metro that could change in future versions.
If you're comfortable with the idea of using an experimental feature, let's look at our complete implementation in config/i18n.ts
:
import { I18n, TranslateOptions } from 'i18n-js'
import * as RNLocalize from 'react-native-localize'
// Dynamic import of translation files
let translations: Record<string, any> = {}
const localeFiles = require.context('../config/translations', true, /\.json$/)
localeFiles.keys().map((key: string) => {
const newKey = key.replace('./', '').replace('.json', '')
translations = {
...translations,
[newKey]: localeFiles(key),
}
})
// Types and fallbacks
type NestedKeys<T, K extends keyof T = keyof T> = K extends string
? T[K] extends Record<string, unknown>
? `${K}.${NestedKeys<T[K], keyof T[K]>}` | K
: K
: never
// NB we're assuming 'en' is always included
type Translations = (typeof translations)['en']
type TranslationKeys = NestedKeys<Translations>
const fallback = { languageTag: 'en' }
const { languageTag } =
RNLocalize.findBestLanguageTag(Object.keys(translations)) || fallback
const i18n = new I18n()
i18n.translations = translations
i18n.locale = languageTag
i18n.enableFallback = true
// Helper function for type-safe translations
export function t(key: TranslationKeys, options?: TranslateOptions): string {
return i18n.t(key, options)
}
export const locale = languageTag
export default i18n
Let's break down what's happening in our implementation:
-
Dynamic Translation Loading: We use Metro's experimental
require.context
feature to dynamically load all JSON files in our translations directory. This automatically imports any new language files you add without code changes. -
Type Safety: The
NestedKeys
type creates a union type of all possible translation keys, including nested paths. This gives us type completion and compile-time checking when using translations. -
Locale Detection: We use
react-native-localize
to find the best matching language based on the user's device settings and if no match is found, we fallback to English ('en'):
const { languageTag } = RNLocalize.findBestLanguageTag(Object.keys(translations)) || fallback
-
i18n Configuration: We configure the i18n instance with our translations and detected locale:
i18n.translations = translations i18n.locale = languageTag i18n.enableFallback = true
The
enableFallback
option means if a translation is missing in the selected language, it will fall back to English. -
Helper Function: Finally, we export a type-safe translation helper:
export function t(key: TranslationKeys, options?: TranslateOptions): string
This function provides type completion for translation keys and ensures we can't accidentally use invalid keys.
With this setup, adding a new language is as simple as creating a new JSON file in the translations directory (e.g., de.json
for German). The dynamic import will automatically pick it up, and if a user's device is set to that language, your app will use those translations without any code changes.
Structured translation files
That's all the dry setup done, we can detect the users locale and load the correct translations. Now let's explore how we organise our strings.
Firstly we base everything off of our default locale which, as an English speaking development team, is en
. Feel free to use a base locale that makes sense for your project.
We've internationalised loads of products across mobile and web and something to be careful of is making your strings an unmaintainable jumble. Its all too easy to end up with a translation file that has 1000s of strings in it, loads of duplicates and a maintenance headache.
What works for our projects is to have two main concepts:
- A defaults key with commonly used strings ie cancel, update, save etc
- Translations that mirror our application structure and naming conventions
This makes it easier to maintain consistency across the application and makes it easier to onboard new team members.
{
"defaults": {
"open": "Open",
"close": "Close",
"cancel": "Cancel",
"ok": "OK",
"yes": "Yes",
"no": "No",
"back": "Back",
"next": "Next",
"done": "Done",
"save": "Save"
},
"components": {
"UploadButton": {
"start": "Start Upload",
"pending": "Upload Pending",
"error": "Upload Failed"
},
},
"screens": {
"Home": {
"title": "Home",
"description": "Welcome to the home screen"
},
"Settings": {
"title": "Settings",
"description": "Welcome to the settings screen"
}
},
"utils": {
"compressImage": {
"success": "Image compressed successfully",
"error": "Failed to compress image"
}
}
}
This structure makes it easier to:
- Locate specific translations
- Maintain consistency across the application
- Onboard new team members
Using Translations in Components
Implementation in components is straightforward:
import { t } from 'app/config/i18n'
import { Text } from 'react-native'
const MyComponent = () => (
<Text>
{t("defaults.welcome")}
</Text>
)
The t
function is type-safe and will give you completion for the keys you can use in your translations.
Automating Translations
Now we have the technical setup we need to actually get the content translated. This can be hard and expensive to do manually, so we've automated the process as much as possible.
To speed up the translation process, we use OpenAI's API to automatically translate our English content to other languages. While machine translation isn't perfect, it provides a solid starting point that can be refined by native speakers as your product scales.
Native platform considerations
Our i18n setup for our React Native app is only translating the 'Javascript' parts of our application. This means to have fully translated content that we need to ensure that any content that is displayed in the native platform is also translated.
iOS (Xcode)
- Open your Xcode project
- Select your project in the navigator
- Click the "Info" tab
- Under "Localizations", click the + button
- Choose your target language
- Create a
InfoPlist.strings
file for each language:
// InfoPlist.strings (French)
"CFBundleDisplayName" = "Mon Application";
Android (Android Studio)
- Navigate to
android/app/src/main/res
- Create a new
values-[language code]
folder (e.g.,values-fr
for French) - Add a
strings.xml
file:
<!-- values-fr/strings.xml -->
<resources>
<string name="app_name">Mon Application</string>
</resources>
Internationalisation best practices
- Use semantic keys: Instead of
button.ok
, useaction.confirm
- Maintain context: Group translations in a way that matches your application structure (ie components, screens, utils)
- Test different languages: Regularly test your app in all supported languages
- Consider text length: Remember that translations may be longer or shorter than English, in React Native
numberOfLines
is your friend
Implementing internationalisation in React Native requires careful planning but pays dividends in accessibility and user satisfaction. Our TypeScript-first approach ensures type safety while maintaining developer productivity.
Need help implementing internationalisation in your React Native application? Add Jam specialises in building robust, scalable mobile applications. Contact us to discuss how we can help make your app global-ready.
This implementation has been battle-tested across multiple production applications built by Add Jam. For more insights into React Native development, check out other React Native blog posts such as Using Live Activities in a React Native App.