How toAdd i18n to a Remix Vite app

Let's start by creating a new Remix application using the Vite plugin.

rmx --template https://github.com/remix-run/remix/tree/main/templates/vite remix-vite-i18n

Now we'll have a remix-vite-i18n folder, there let's install our dependencies:

npm add i18next i18next-browser-languagedetector react-i18next remix-i18next

Now let's create two translation files, we will add support for English and Spanish, so let's create the following files

app/locales/en.ts
export default { title: "remix-i18next (en)", description: "A Remix + Vite + remix-i18next example", };
app/locales/es.ts
export default { title: "remix-i18next (es)", description: "Un ejemplo de Remix + Vite + remix-i18next", };

Now we need to setup the i18next configuration.

app/config/i18n.ts
import en from "~/locales/en"; import es from "~/locales/es"; // This is the list of languages your application supports, // the fallback is always the last export const supportedLngs = ["es", "en"]; // This is the language you want to use in case // if the user preferred language is not in the supportedLngs export const fallbackLng = "en"; // The default namespace of i18next is "translation", but you can customize it // here export const defaultNS = "translation"; // These are the translation files we created, `translation` is the namespace // we want to use, we'll use this to include the translations in the bundle // instead of loading them on-demand export const resources = { en: { translation: en }, es: { translation: es }, };

Our next step is to create an instance of RemixI18next.

app/modules/i18n.server.ts
import { RemixI18Next } from "remix-i18next/server"; // We import everything from our configuration file import * as i18n from "~/config/i18n"; export default new RemixI18Next({ detection: { supportedLanguages: i18n.supportedLngs, fallbackLanguage: i18n.fallbackLng, }, // This is the configuration for i18next used // when translating messages server-side only i18next: { ...i18n, // You can add extra keys here }, });

And let's update our entry.client.tsx and entry.server.tsx files.

app/entry.client.tsx
import { RemixBrowser } from "@remix-run/react"; import i18next from "i18next"; import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector"; import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { I18nextProvider, initReactI18next } from "react-i18next"; import { getInitialNamespaces } from "remix-i18next/client"; import * as i18n from "~/config/i18n"; async function main() { await i18next .use(initReactI18next) .use(I18nextBrowserLanguageDetector) .init({ ...i18n, ns: getInitialNamespaces(), detection: { order: ["htmlTag"], caches: [] }, }); startTransition(() => { hydrateRoot( document, <I18nextProvider i18n={i18next}> <StrictMode> <RemixBrowser /> </StrictMode> </I18nextProvider>, ); }); } main().catch((error) => console.error(error));
app/entry.server.tsx
import { PassThrough } from "node:stream"; import type { AppLoadContext, EntryContext } from "@remix-run/node"; import { createReadableStreamFromReadable } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; import { isbot } from "isbot"; import { renderToPipeableStream } from "react-dom/server"; import { createInstance } from "i18next"; import i18nServer from "./modules/i18n.server"; import { I18nextProvider, initReactI18next } from "react-i18next"; import * as i18n from "./config/i18n"; const ABORT_DELAY = 5_000; export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, loadContext: AppLoadContext, ) { // Removed for brevity } async function handleBotRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, ) { let instance = createInstance(); let lng = await i18nServer.getLocale(request); let ns = i18nServer.getRouteNamespaces(remixContext); await instance.use(initReactI18next).init({ ...i18n, lng, ns, }); return new Promise((resolve, reject) => { let shellRendered = false; let { pipe, abort } = renderToPipeableStream( <I18nextProvider i18n={instance}> <RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} /> </I18nextProvider>, { onAllReady() { // Removed for brevity }, onShellError(error: unknown) { // Removed for brevity }, onError(error: unknown) { // Removed for brevity }, }, ); setTimeout(abort, ABORT_DELAY); }); } async function handleBrowserRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, ) { let instance = createInstance(); let lng = await i18nServer.getLocale(request); let ns = i18nServer.getRouteNamespaces(remixContext); await instance.use(initReactI18next).init({ ...i18n, lng, ns, }); return new Promise((resolve, reject) => { let shellRendered = false; let { pipe, abort } = renderToPipeableStream( <I18nextProvider i18n={instance}> <RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} /> </I18nextProvider>, { onShellReady() { // Removed for brevity }, onShellError(error: unknown) { // Removed for brevity }, onError(error: unknown) { // Removed for brevity }, }, ); setTimeout(abort, ABORT_DELAY); }); }

With this configured, we can start using in, let's go to our app/root.tsx and detect the user locale in the loader and use it in the UI.

app/root.tsx
import { LoaderFunctionArgs, json } from "@remix-run/node"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData, useRouteLoaderData, } from "@remix-run/react"; import i18nServer from "./modules/i18n.server"; import { useChangeLanguage } from "remix-i18next/react"; // We'll configure the namespace to use here export const handle = { i18n: ["translation"] }; export async function loader({ request }: LoaderFunctionArgs) { let locale = await i18nServer.getLocale(request); // get the locale return json({ locale }); } export function Layout({ children }: { children: React.ReactNode }) { // Here we need to find the locale from the root loader data, if available // we'll use it as the `<html lang>`, otherwise fallback to English let loaderData = useRouteLoaderData<typeof loader>("root"); return ( <html lang={loaderData?.locale ?? "en"}>{/* removed for brevity */}</html> ); } export default function App() { let { locale } = useLoaderData<typeof loader>(); useChangeLanguage(locale); // Change i18next language if locale changes return <Outlet />; }

And let's go to a route (we'll use the index) and use getFixedT in the loader to translate messages and useTranslation in the UI.

app/routes/_index.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import i18nServer from "~/modules/i18n.server"; export async function loader({ request }: LoaderFunctionArgs) { let t = await i18nServer.getFixedT(request); return json({ description: t("description") }); } export default function Index() { let { description } = useLoaderData<typeof loader>(); let { t } = useTranslation(); return ( <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}> <h1>{t("title")}</h1> <p>{description}</p> </div> ); }

We can now open our app and add ?lng=es to switch to Spanish or ?lng=en to use English (or remove ?lng since English is the default). Let's see how we can use a cookie to persist the user locale so even if they remove ?lng=es it will keep receiving the application in Spanish.

First in our index route, we will add a Form to let the user change the language.

app/routes/_index.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import i18nServer from "~/modules/i18n.server"; export async function loader({ request }: LoaderFunctionArgs) { let t = await i18nServer.getFixedT(request); return json({ description: t("description") }); } export default function Index() { let { t } = useTranslation(); let { description } = useLoaderData<typeof loader>(); return ( <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}> <h1>{t("title")}</h1> <p>{description}</p> <Form> <button type="submit" name="lng" value="es"> Español </button> <button type="submit" name="lng" value="en"> English </button> </Form> </div> ); }

Now let's go back to the file where we instantiated RemixI18next and create a cookie.

app/modules/i18n.server.ts
import { createCookie } from "@remix-run/node"; import { RemixI18Next } from "remix-i18next/server"; // We import everything from our configuration file import * as i18n from "~/config/i18n"; export const localeCookie = createCookie("lng", { path: "/", sameSite: "lax", secure: process.env.NODE_ENV === "production", httpOnly: true, }); export default new RemixI18Next({ detection: { supportedLanguages: i18n.supportedLngs, fallbackLanguage: i18n.fallbackLng, cookie: localeCookie, }, // This is the configuration for i18next used // when translating messages server-side only i18next: { ...i18n, // You can add extra keys here }, });

We're creating this localeCookie object, and passing it to RemixI18Next, this way when we call getLocale it will check if the cookie is set and has a value and try to use it.

And we can go to our app/root.tsx to set the cookie.

app/root.tsx
// Other imports import i18nServer, { localeCookie } from "./modules/i18n.server"; export async function loader({ request }: LoaderFunctionArgs) { let locale = await i18nServer.getLocale(request); // get the locale return json( { locale }, { headers: { "Set-Cookie": await localeCookie.serialize(locale) } }, ); } // Rest of the code

And that's it, now if the user clicks a button in our form, it will add the lng search param, the locale will change and be persisted in a cookie, after removing it the cookie will be used to know what locale to use.