Used

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

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

Now we need to setup the i18next configuration.

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.

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.

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));
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.

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.

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.

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.

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.

// 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.