Lynx

Internationalization

Internationalization (i18n) refers to the design and development of products and applications to enable localization, making them suitable for users from different cultures, regions, or languages. You can use i18n libraries like i18next to achieve internationalization and provide an accessible experience for users.

Intl API

The Intl object is a namespace for the ECMAScript Internationalization API, providing a set of methods for handling internationalization and localization. With the Intl API, you can handle issues related to numbers, dates, and times, such as number formatting and date and time formatting.

Currently, the Intl API is not implemented in Lynx but will be supported in future versions. If you need to use the Intl API in Lynx, you can install the corresponding polyfills, such as @formatjs/intl-numberformat, @formatjs/intl-datetimeformat, and intl-pluralrules.

Using i18next

i18next is an internationalization-framework written in and for JavaScript. Using it in ReactLynx gives you:

  1. Simplicity: i18next provides an easy-to-use API, making it simple to implement internationalization in ReactLynx applications.
  2. Dynamic Loading: Supports on-demand loading of language resources, reducing initial load time.
  3. Wide Support: Compatible with various formats and backends, allowing easy integration with different translation storage solutions such as JSON files, remote APIs, etc.
  4. Caching: Built-in caching mechanism speeds up the loading of language resources, enhancing user experience.
  5. Rich Community Support: A vast community and a wealth of plugins available to meet diverse internationalization needs.
  6. Reliability: Proven in numerous projects, offering stability and reliability.
  7. Hot Reloading: Changes to language resources can take effect immediately without needing to republish the application.

Installation

You need to install the i18next package:

npm
yarn
pnpm
bun
deno
npm install i18next@^23.16.8
Tip

Since the version 24.0.0+ of i18next, the running environment is required to have the Intl.pluralRules API. However, this implementation is currently not available on Lynx.

This means that you need to:

  1. Use v23 and must enable compatibilityJSON: 'v3'.
  2. Use v24 and need to polyfill the Intl.PluralRules API.

Create the first translation

Imagine we have a locale file src/locales/en.json like this:

src/locales/en.json
{
  "world": "World"
}

Creating the translation function is as simple as these 3 steps:

  1. Import the locale JSON file ./locales/en.json.
  2. Create an i18next instance with the createInstance() function.
  3. Initialize the i18n with the locale resource.
src/i18n.ts
import i18next from 'i18next';
import type { i18n } from 'i18next';

import enTranslation from './locales/en.json';

const localI18nInstance: i18n = i18next.createInstance();

localI18nInstance.init({
  lng: 'en',
  // The default JSON format needs `Intl.PluralRules` API, which is currently unavailable in Lynx.
  compatibilityJSON: 'v3',
  resources: {
    en: {
      translation: enTranslation, // `translation` is the default namespace
    },
  },
});

export { localI18nInstance as i18n };
Tip

If you import *.json in TypeScript file, you may need to set compilerOptions.resolveJsonModule to true in your tsconfig.json file.

tsconfig.json
{
  "compilerOptions": {
    "resolveJsonModule": true
  }
}

Then, the i18n.t function can be used for translations:

src/App.tsx
import { useEffect } from '@lynx-js/react';

import { i18n } from './i18n.js'; 

export function App() {
  useEffect(() => {
    console.log(`Hello, ReactLynx x i18next!`);
  }, []);

  return (
    <view>
      <text>Hello, {i18n.t('world')}</text>
    </view>
  );
}

Load resources synchronously

In a real world project, there are usually multiple resource files for different languages.

Instead of static import them one-by-one, you may use the import.meta.webpackContext API of Rspack to statically import all the JSON files.

import one-by-one
// Static-imported locales that can be shown at first screen
import enTranslation from './locales/en.json';
import zhTranslation from './locales/zh.json';
import itTranslation from './locales/it.json';
import jpTranslation from './locales/jp.json';
import deTranslation from './locales/de.json';
import esTranslation from './locales/es.json';
import frTranslation from './locales/fr.json';
import idTranslation from './locales/id.json';
import ptTranslation from './locales/pt.json';
import.meta.webpackContext
const localesContext = import.meta.webpackContext('./locales', {
  recursive: false,
  regExp: /\.json$/,
});
const enTranslation = localesContext('en.json');

These resources can be added to i18next.init() to make translation work at the first screen.

src/i18n.ts
import i18next from 'i18next';
import type { i18n } from 'i18next';

// Localizations imported statically, available at the initial screen
const localesContext = import.meta.webpackContext('./locales', {
  recursive: false,
  regExp: /\.json$/,
});

const localI18nInstance: i18n = i18next.createInstance();

localI18nInstance.init({
  lng: 'en',
  // The default JSON format needs Intl.PluralRules API, which is currently unavailable in Lynx.
  compatibilityJSON: 'v3',
  // Add all statically imported localizations to i18next resources.
  resources: Object.fromEntries(
    localesContext.keys().map((key) => [
      key.match(/\/([^/]+)\.json$/)?.[1] || key,
      {
        translation: localesContext(key) as Record<string, string>,
      },
    ]),
  ),
});

export { localI18nInstance as i18n };
Tip

You may need Rspeedy Type Declaration for import.meta.webpackContext.

Load resources asynchronously and lazily

Instead of bundling all the locales, we can use dynamic imports (import()) to load the locales lazily and asynchronously.

You need to install the i18next-resources-to-backend package:

npm
yarn
pnpm
bun
deno
npm install i18next-resources-to-backend

Then add the following code to src/i18n.ts:

src/i18n.ts
import i18next from 'i18next';
import type { i18n } from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';

// Localizations imported statically, available at the initial screen
const localesContext = import.meta.webpackContext('./locales', {
  recursive: false,
  regExp: /(en|zh)\.json$/,
});

const localI18nInstance: i18n = i18next.createInstance();

// We can only loading resources on a background thread
if (__JS__) {
  localI18nInstance.use(
    // See: https://www.i18next.com/how-to/add-or-load-translations#lazy-load-in-memory-translations
    resourcesToBackend(
      (language: string) =>
        // Dynamic-imported locales can be used with `i18n.loadLanguages`
        import(`./locales/${language}.json`),
    ),
  );
}

localI18nInstance.init({
  lng: 'en',
  // The default JSON format needs Intl.PluralRules API, which is currently unavailable in Lynx.
  compatibilityJSON: 'v3',
  // Add all statically imported localizations to i18next resources.
  resources: Object.fromEntries(
    localesContext.keys().map((key) => [
      key.match(/\/([^/]+)\.json$/)?.[1] || key,
      {
        translation: localesContext(key) as Record<string, string>,
      },
    ]),
  ),
  partialBundledLanguages: true,
});

export { localI18nInstance as i18n };
  1. An i18next backend i18next-resources-to-backend has been added to the background thread with localI18nInstance.use.

  2. The languages can be loaded asynchronously (with some of them being loaded synchronously).

You will see two async JS chunks are created in the output:

src_locales_it-IT_json.js
'use strict';
exports.ids = ['src_locales_it-IT_json'];
exports.modules = {
  './src/locales/it-IT.json': function (module) {
    module.exports = JSON.parse('{"world": "Mondo"}');
  },
};
src_locales_ja-JP_json.js
'use strict';
exports.ids = ['src_locales_ja-JP_json'];
exports.modules = {
  './src/locales/ja-JP.json': function (module) {
    module.exports = JSON.parse('{"world": "世界"}');
  },
};
💡 Why is there no async chunk generated by src/locales/en.json

This is because this module is already included in the main chunk. Webpack/Rspack will remove it automatically. See: optimization.removeAvailableModules and optimization.removeEmptyChunks for details.

You may also see that these two chunks are not loaded. This is why it is called lazily. The request to the resources is only sent when needed.

You may also see that these two chunks are not loaded. This is why it is called lazily. The request to the resources is only sent when needed.

Change between languages

The i18next.changeLanguage API can be used for changing between languages.

src/App.tsx
import { useEffect, useState } from '@lynx-js/react';

import { i18n } from './i18n.js';

export function App() {
  const [locale, setLocale] = useState('en');

  useEffect(() => {
    console.log('Hello, ReactLynx3 x i18next!');
  }, []);

  const getNextLocale = (locale: string) => {
    // mock locales
    const locales = ["en", "zh-CN"];
    const index = locales.indexOf(locale);
    return locales[(index + 1) % locales.length];
  };
  return (
    <view>
      <text style={{ color: 'red' }}>Current locale: {locale}</text>
      <text
        bindtap={async () => {
          const nextLocale = getNextLocale(locale);
          await i18n.changeLanguage(nextLocale);
          setLocale(nextLocale);
        }}
      >
        Tap to change locale
      </text>
      <text>Hello, {i18n.t('world')}</text>
    </view>
  );
}

Extracting Translations

If you downloaded many translations but your project only uses some of them, you may need to extract the translations to reduce the bundle size.

There are two ways to extract the translations used in your source code.

You can use the rsbuild-plugin-i18next-extractor to extract the translations used in the source code. This plugin is based on i18next-cli, and uses the Rspack module graph to extract i18n messages from modules that are actually imported by the current build. This allows it to extract translations on demand, avoid bundling unused messages, and reduce the risk of missing translations that are used through imported dependencies.

Tip

This plugin requires Node.js 22 or above.

First, install the plugin and its i18next-cli peer dependency:

npm
yarn
pnpm
bun
deno
npm install rsbuild-plugin-i18next-extractor i18next-cli -D

Enable the plugin in lynx.config.ts and specify the localesDir option, which is the directory path of the raw translations:

The examples below assume your raw translations live in src/locales/.

lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginI18nextExtractor } from 'rsbuild-plugin-i18next-extractor';

export default defineConfig({
  plugins: [
    pluginI18nextExtractor({
      localesDir: './src/locales',
    }),
  ],
});

You should import the translations from the localesDir which you specify above:

src/i18n.ts
import i18next from 'i18next';
import type { i18n } from 'i18next';

import enTranslation from './locales/en.json';

const localI18nInstance: i18n = i18next.createInstance();

localI18nInstance.init({
  lng: 'en',
  resources: {
    en: {
      translation: enTranslation,
    },
  },
});
Tip

After running DEBUG=rsbuild:i18next rspeedy dev or DEBUG=rsbuild:i18next rspeedy build, you can view the extracted translations in the node_modules/.rsbuild-plugin-i18next-extractor directory.

Configuration Options

The plugin scans node_modules by default. You can exclude files using the i18nextToolkitConfig.extract.ignore option:

lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginI18nextExtractor } from 'rsbuild-plugin-i18next-extractor';

export default defineConfig({
  plugins: [
    pluginI18nextExtractor({
      localesDir: './src/locales',
      i18nextToolkitConfig: {
        extract: {
          ignore: ['node_modules/**'],
        },
      },
    }),
  ],
});

You can also provide a callback for missing translation keys:

lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginI18nextExtractor } from 'rsbuild-plugin-i18next-extractor';

export default defineConfig({
  plugins: [
    pluginI18nextExtractor({
      localesDir: './src/locales',
      onKeyNotFound: (key, locale, localeFilePath, entryName) => {
        console.warn(`Missing translation key: ${key} for locale: ${locale}`);
      },
    }),
  ],
});

Advanced: Dedupe translations in Lynx bundles

For Lynx apps, it is recommended to use @lynx-js/i18next-translation-dedupe together with rsbuild-plugin-i18next-extractor to avoid bundling the same translations twice.

@lynx-js/i18next-translation-dedupe reads the translations extracted by rsbuild-plugin-i18next-extractor, skips the extractor's default rendered asset, and writes the translations into the Lynx bundle customSections for runtime loading.

First, install the package:

npm
yarn
pnpm
bun
deno
npm install @lynx-js/i18next-translation-dedupe -D

Then add pluginLynxI18nextTranslationDedupe():

lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginLynxI18nextTranslationDedupe } from '@lynx-js/i18next-translation-dedupe';
import { pluginI18nextExtractor } from 'rsbuild-plugin-i18next-extractor';

export default defineConfig({
  plugins: [
    pluginI18nextExtractor({
      localesDir: './src/locales',
    }),
    pluginLynxI18nextTranslationDedupe(),
  ],
});

At runtime, load the extracted translations from the Lynx bundle customSections:

src/i18n.ts
import i18next from 'i18next';
import type { i18n } from 'i18next';
import { loadI18nextTranslations } from '@lynx-js/i18next-translation-dedupe';

const localI18nInstance: i18n = i18next.createInstance();

localI18nInstance.init({
  lng: 'en',
  resources: loadI18nextTranslations(),
});

CLI

You can also use the i18next-cli to extract translations.

Tip

The i18next-cli requires Node.js 22 or above.

First, install the CLI:

npm
yarn
pnpm
bun
deno
npm install i18next-cli -D

Add an i18next.config.ts file at the project root. For example:

i18next.config.ts
import { defineConfig } from 'i18next-cli';

export default defineConfig({
  locales: ['en', 'zh'],
  extract: {
    input: ['src/**/*.{js,jsx,ts,tsx}'],
    output: 'src/locales/{{language}}.json',
  },
});

You can adjust locales, extract.input, and extract.output to match your project structure. If you use multiple namespaces, include {{namespace}} in the output pattern.

Then run the following command to extract translations:

npm
yarn
pnpm
bun
deno
npm i18next-cli extract
Except as otherwise noted, this work is licensed under a Creative Commons Attribution 4.0 International License, and code samples are licensed under the Apache License 2.0.