Setup
Step-by-step installation and configuration for @better-i18n/remix
Get @better-i18n/remix running in your Remix app in 5 steps.
Install the package
npm install @better-i18n/remixbun add @better-i18n/remixpnpm add @better-i18n/remixyarn add @better-i18n/remixCreate the i18n singleton
Create app/i18n.server.ts at module scope. This ensures a single TtlCache instance is shared across all requests, avoiding redundant CDN fetches.
import { createRemixI18n } from "@better-i18n/remix";
export const i18n = createRemixI18n({
project: "my-company/web-app", // Your project identifier
defaultLocale: "en",
});Important: Always instantiate at module scope (top-level export const). Creating a new instance per request defeats caching and causes unnecessary CDN calls.
Load translations in your server entry
In your server.ts (or entry.server.ts), load messages and locales before handling requests:
import { i18n } from "~/i18n.server";
export default {
async fetch(request: Request): Promise<Response> {
// Detect locale from URL or Accept-Language header
const languages = await i18n.getLanguages();
const locale = getLocaleFromURL(request, languages) || "en";
// Load translations and available locales in parallel
const [messages, locales] = await Promise.all([
i18n.getMessages(locale),
i18n.getLocales(),
]);
const handleRequest = createRequestHandler({
build: remixBuild,
getLoadContext() {
return { locale, messages, locales };
},
});
return handleRequest(request);
},
};For Shopify Hydrogen, see the dedicated Hydrogen guide for the full server.ts setup with createStorefrontClient.
Pass data from root loader
In root.tsx, return locale, messages, and locales from the loader and set <html lang>:
import {
Links, Meta, Outlet, Scripts,
ScrollRestoration, useLoaderData,
} from "react-router";
export async function loader({ context }: LoaderFunctionArgs) {
return {
locale: context.locale,
messages: context.messages,
locales: context.locales,
};
}
export default function App() {
const { locale } = useLoaderData<typeof loader>();
return (
<html lang={locale} dir="ltr"> // [!code highlight]
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}Add a translation provider
Wrap your app with a translation provider to use hook-based translations with ICU formatting, plurals, and interpolation support.
Install i18next and react-i18next:
bun add i18next react-i18nextCreate a helper that builds an i18next instance from your CDN translations:
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import type { Messages } from "@better-i18n/remix";
export function createI18nextInstance(locale: string, messages: Messages) {
const instance = i18next.createInstance();
instance.use(initReactI18next).init({
lng: locale,
resources: { [locale]: messages },
initImmediate: false, // sync init — required for SSR
lowerCaseLng: true,
defaultNS: "common",
fallbackNS: "common",
interpolation: { escapeValue: false },
});
return instance;
}Wrap <Outlet /> in your root.tsx:
import { useMemo } from "react";
import { I18nextProvider } from "react-i18next";
import { createI18nextInstance } from "~/lib/i18n-client";
export default function App() {
const { locale, messages } = useLoaderData<typeof loader>();
const i18nInstance = useMemo(
() => createI18nextInstance(locale, messages),
[locale, messages],
);
return (
<html lang={locale} dir="ltr">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<I18nextProvider i18n={i18nInstance}> {}
<Outlet />
</I18nextProvider> {}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}Use useTranslation() in any route:
import { useTranslation } from "react-i18next";
export default function Home() {
const { t } = useTranslation("common");
return <h1>{t("welcome")}</h1>;
}The @better-i18n/remix/react entrypoint provides a use-intl-based provider with built-in hooks:
import { RemixI18nProvider } from "@better-i18n/remix/react";
export default function App() {
const { locale, messages, languages } = useLoaderData<typeof loader>();
return (
<html lang={locale} dir="ltr">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<RemixI18nProvider locale={locale} messages={messages} languages={languages}> {}
<Outlet />
</RemixI18nProvider> {}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}Use useTranslations() in any route:
import { useTranslations } from "@better-i18n/remix/react";
export default function Home() {
const t = useTranslations("common");
return <h1>{t("welcome")}</h1>;
}meta() functions cannot use React hooks. Use the msg() helper for meta tags and other non-hook contexts.
Middleware-Based i18next (remix-i18next)
For full i18n support with ICU formatting, plurals, and interpolation, use @better-i18n/remix/i18next with remix-i18next:
bun add i18next react-i18next remix-i18nextimport { createRemixI18n } from "@better-i18n/remix";
import { buildI18nextConfig } from "@better-i18n/remix/i18next";
import { createI18nextMiddleware } from "remix-i18next/middleware";
const i18n = createRemixI18n({ project: "my-company/web-app", defaultLocale: "en" });
const config = await buildI18nextConfig({ i18n });
export const [i18nextMiddleware, getLocale, getInstance] =
createI18nextMiddleware({
detection: {
supportedLanguages: config.supportedLanguages,
fallbackLanguage: config.fallbackLanguage,
},
i18next: {
resources: config.resources,
...config.i18nextOptions,
},
});Then use standard react-i18next hooks in your components:
import { useTranslation } from "react-i18next";
export default function Homepage() {
const { t } = useTranslation("home");
return <h1>{t("welcome")}</h1>; // ICU, plurals, interpolation all work
}buildI18nextConfig() fetches translations from the CDN and converts them to i18next resource format. It also returns languages (with nativeName and isDefault fields) for building language switchers.
Content Security Policy
If your app uses CSP, add cdn.better-i18n.com to your connect-src directive:
The SDK fetches translations from cdn.better-i18n.com at runtime. Without this CSP entry, requests will be blocked.
const { nonce, header, NonceProvider } = createContentSecurityPolicy({
connectSrc: [
"'self'",
"cdn.better-i18n.com",
],
});Next Steps
- Set up locale detection from headers or URLs
- Add locale-prefixed routing with
LocaleLink - For Hydrogen stores, follow the Hydrogen guide