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.