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 locale = getLocaleFromURL(request) || "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>
  );
}

Create a message helper

Since CDN messages are typed as Record<string, unknown>, create a type-safe helper to extract strings:

app/lib/messages.ts
/**
 * Type-safe message accessor.
 * Safely extracts a string value from a namespace, with fallback.
 */
export function msg(
  ns: Record<string, unknown> | undefined,
  key: string,
  fallback: string,
): string {
  const val = ns?.[key];
  return typeof val === "string" ? val : fallback;
}

Usage in components:

app/routes/_index.tsx
import { useLoaderData } from "react-router";
import { msg } from "~/lib/messages";

export default function Home() {
  const { messages } = useLoaderData<typeof loader>();
  const common = messages.common; // namespace

  return (
    <h1>{msg(common, "welcome", "Welcome")}</h1>
  );
}

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