Better I18NBetter I18N
Remix & Hydrogen

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/remix
bun add @better-i18n/remix
pnpm add @better-i18n/remix
yarn add @better-i18n/remix

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

app/i18n.server.ts
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:

server.ts
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>:

app/root.tsx
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-i18next

Create a helper that builds an i18next instance from your CDN translations:

app/lib/i18n-client.ts
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:

app/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:

app/routes/($locale)._index.tsx
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:

app/root.tsx
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:

app/routes/($locale)._index.tsx
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-i18next
app/middleware/i18next.ts
import { 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:

app/routes/($locale)._index.tsx
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.

entry.server.tsx
const { nonce, header, NonceProvider } = createContentSecurityPolicy({
  connectSrc: [
    "'self'",
    "cdn.better-i18n.com", 
  ],
});

Next Steps

On this page