Setup i18next

react-i18next is an internationalization library that helps applications manage translations, language detection, and localization logic. It provides a flexible API for loading translation files, formatting text, handling plurals, and switching languages at runtime.

Install the packages

To set up i18next, first, open a terminal at the root of the host application and install the following packages:

pnpm add @squide/i18next i18next i18next-browser-languagedetector react-i18next

Register the plugin

Then, refer to the create host application guide as a starting point and update the host application boostrapping code to register an instance of the i18nextplugin with the FireflyRuntime instance:

import { createRoot } from "react-dom/client";
import { FireflyProvider, initializeFirefly } from "@squide/firefly";
import { i18nextPlugin } from "@squide/i18next";
import { App } from "./App.tsx";
import { registerHost } from "./register.tsx";

const runtime = initializeFirefly({
    localModules: [registerHost],
    plugins: [x => {
        // In this example:
        // - The supported languages are "en-US" and "fr-CA"
        // - The fallback language is "en-US"
        // - The URL querystring parameter to detect the current language is "language"
        const i18nextPlugin = new i18nextPlugin(x, ["en-US", "fr-CA"], "en-US", "language");

        // Always detect the user language early on.
        i18nextPlugin.detectUserLanguage();

        return i18nextPlugin;
    }]
});

const root = createRoot(document.getElementById("root")!);

root.render(
    <FireflyProvider runtime={runtime}>
        <App />
    </FireflyProvider>
);

By calling the detectUserLanguage method of the plugin instance, the user language is automatically detected. Applications should always detect the user language at bootstrapping, even if the current language is expected to be overriden by a preferred language setting once the user information has been loaded.

The language detection happens in the following order:

  1. Deduce from a ?language querystring parameter.
  2. Deduce from the user navigator language settings.
  3. Use the fallback language, which is en-US in this example.

Integrate a backend language setting

For many applications, the displayed language is expected to be derived from an application specific user "preferred language" setting stored in a remote database. Therefore, the frontend remains unaware of this setting value until the user session is loaded.

Hence, the strategy to select the displayed language should be as follow:

  1. Use the language detected at bootstrapping for anonymous users (with the detectUserLanguage method previously called).
  2. Upon user authentication and session loading, if a "preferred language" setting is available from the session data, update the displayed language to reflect this preference.

This strategy can be implemented with the help of the useChangeLanguage and useProtectedDataQueries hooks:

import { AppRouter, useProtectedDataQueries, useIsBootstrapping, useChangeLanguage } from "@squide/firefly";
import { createBrowserRouter, Outlet } from "react-router";
import { RouterProvider } from "react-router/dom";
import { ApiError, isApiError, type Session } from "@sample/shared";

function BootstrappingRoute() {
    const [session] = useProtectedDataQueries([
        {
            queryKey: ["/api/session"],
            queryFn: async () => {
                const response = await fetch("/api/session");

                if (!response.ok) {
                    throw new ApiError(response.status, response.statusText);
                }

                const data = await response.json();

                const result: Session = {
                    user: {
                        name: data.username,
                    }
                };

                return result;
            }
        }
    ], error => isApiError(error) && error.status === 401);

    const changeLanguage = useChangeLanguage();

    useEffect(() => {
        if (session) {
            // When the session has been retrieved, update the language to match the user
            // preferred language.
            changeLanguage(session.user.preferredLanguage);
        }
    }, [session, changeLanguage]);

    if (useIsBootstrapping()) {
        return <div>Loading...</div>;
    }

    return <Outlet />;
}

export function App() {
    return (
        <AppRouter waitForProtectedData>
            {({ rootRoute, registeredRoutes, routerProps, routerProviderProps }) => {
                return (
                    <RouterProvider
                        router={createBrowserRouter([
                            {
                                element: rootRoute,
                                children: [
                                    {
                                        element: <BootstrappingRoute />,
                                        children: registeredRoutes
                                    }
                                ]
                            }
                        ], routerProps)}
                        {...routerProviderProps}
                    />
                );
            }}
        </AppRouter>
    );
}
export interface User {
    name: string;
}

export interface Session {
    user: User;
}
export class ApiError extends Error {
    readonly #status: number;
    readonly #statusText: string;
    readonly #stack?: string;

    constructor(status: number, statusText: string, innerStack?: string) {
        super(`${status} ${statusText}`);

        this.#status = status;
        this.#statusText = statusText;
        this.#stack = innerStack;
    }

    get status() {
        return this.#status;
    }

    get statusText() {
        return this.#statusText;
    }

    get stack() {
        return this.#stack;
    }
}

export function isApiError(error?: unknown): error is ApiError {
    return error !== undefined && error !== null && error instanceof ApiError;
}

Configure a module

Define a localized resource file

First, create localized resource files for the en-US and fr-CA locales:

./locales/en-US.json
{
    "navigationItems": {
        "page": "Page - en-US"
    },
    "Page": {
        "bodyText": "Hello from Page!"
    }
}
./locales/fr-CA.json
{
    "navigationItems": {
        "page": "Page - fr-CA"
    },
    "Page": {
        "bodyText": "Bonjour depuis la page!"
    }
}

Register an i18next instance

Then, update the host application local module's register function to create and register an i18next instance with the i18nextPlugin instance. Due to how the internals of i18next works, each module (including the host application) must create its own instance of the third-party library. The i18nextPlugin instance will handle synchronizing the language changes across all i18next instances:

import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { getI18nextPlugin } from "@squide/i18next";
import { Page } from "./Page.tsx";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import resourcesEn from "./locales/en-US/resources.json";
import resourcesFr from "./locales/fr-CA/resources.json";

export const registerHost: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    const i18nextPlugin = getI18nextPlugin(runtime);

    const i18nextInstance = i18n
        .createInstance()
        .use(initReactI18next);

    i18nextInstance.init({
        // Create the instance with the language that has been detected earlier in the bootstrapping code.
        lng: i18nextPlugin.currentLanguage,
        resources: {
            "en-US": resourcesEn,
            "fr-CA": resourcesFr
        }
    });

    // Will associate the instance with the "local-module" key.
    i18nextPlugin.registerInstance("local-module", i18nextInstance);

    runtime.registerRoute({
        path: "/page",
        element: <Page />
    });
};

Localize a page resource

Next, follow the localize resources essential page to use the newly created localized resource.

Lazy-load the resources

With the setup described so far, the resources of every supported language are bundled with the module and land in its initial chunk. The i18nextPlugin can instead load the resources of a language on demand: the language detected at bootstrapping when the instance is registered, then any other language before switching to it. The application only downloads the active language, and useIsBootstrapping stays true until the resources of the current language are loaded, so a page never renders raw resource keys.

Register a lazy instance

Initialize the instance with an empty resources object and provide a loadResources function when registering the instance. The function receives a language and resolves to a map of namespace to resource bundle, the same shape as a single language entry of the i18next resources option:

import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { getI18nextPlugin, type LoadResourcesFunction } from "@squide/i18next";
import { Page } from "./Page.tsx";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";

// Each dynamic import becomes a chunk, only the active language is downloaded.
const loadResources: LoadResourcesFunction = async language => {
    const module = await import(`./locales/${language}/resources.json`, { with: { type: "json" } });

    return module.default;
};

export const registerHost: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    const i18nextPlugin = getI18nextPlugin(runtime);

    const i18nextInstance = i18n
        .createInstance()
        .use(initReactI18next);

    i18nextInstance.init({
        lng: i18nextPlugin.currentLanguage,
        // A lazy instance must be initialized with an empty "resources" object so that i18next initializes
        // synchronously and creates the store filled by the plugin.
        resources: {}
    });

    i18nextPlugin.registerInstance("local-module", i18nextInstance, {
        loadResources
    });

    runtime.registerRoute({
        path: "/page",
        element: <Page />
    });
};

Align the detected language with the preferred language

The modules register before any global data is fetched, therefore the plugin loads the resources of the language detected at bootstrapping when an instance is registered: the querystring parameter, the navigator language or the fallback language. The user preferred language is only known once the session is loaded, and the switch then downloads its resources before the first protected page renders.

When the detected language differs from the preferred language, both languages are downloaded: the detected one at registration, the preferred one when the session is loaded.

To make them match for every returning user, persist the preferred language in the local storage once the session is loaded, and detect it before the navigator language by adding the localStorage source to the plugin detection order:

host/src/index.tsx
const runtime = initializeFirefly({
    localModules: [registerHost],
    plugins: [x => {
        const i18nextPlugin = new i18nextPlugin(x, ["en-US", "fr-CA"], "en-US", "language", {
            detection: {
                order: ["querystring", "localStorage", "navigator"],
                lookupLocalStorage: "preferred-language"
            }
        });

        i18nextPlugin.detectUserLanguage();

        return i18nextPlugin;
    }]
});
useEffect(() => {
    if (session) {
        // Persisted for the next visit, so the detection loads the preferred language right away.
        localStorage.setItem("preferred-language", session.user.preferredLanguage);

        changeLanguage(session.user.preferredLanguage);
    }
}, [session, changeLanguage]);

Try it 🚀

Start the application in a development environment using the dev script. Navigate to /page, the page content and the navigation item should render the english (en-US) resources. Then append ?language=fr-CA to the URL. The page content and the navigation item should now render the french (fr-CA) resources.

Troubleshoot issues

If you are experiencing issues with this guide:

  • Open the DevTools console. You'll find a log entry for each i18next instance that is being registered and another log everytime the language is changed:
    • [squide] Registered a new i18next instance with key "local-module".
    • [squide] The language has been changed to "fr-CA".
  • When the resources are lazy-loaded, you'll also find a log entry for each language loaded into an instance, and an error entry for a failed load:
    • [squide] Loaded the "fr-CA" resources of the i18next instance with key "local-module".
    • [squide] An error occurred while loading the "fr-CA" resources of the i18next instance with key "local-module":
  • Refer to a working example on GitHub.
  • Refer to the troubleshooting page.