SEO

Remix i18n: Internationalization for Remix Applications

Eray Gündoğmuş
Eray Gündoğmuş
·12 min read
Share
Remix i18n: Internationalization for Remix Applications

Remix i18n: Internationalization for Remix Applications

Remix's server-first architecture makes internationalization both powerful and distinctive compared to purely client-side frameworks. With Remix, you can detect locale on the server, load translations server-side, and send fully localized HTML to the client—no flash of untranslated content, no client-side locale detection delays.

This guide covers how to implement i18n in Remix from scratch, including routing, locale detection, translation loading, and integration with remix-i18next, the most popular i18n library for Remix.

Why Remix i18n Is Different

Remix runs code on the server in loaders and actions, and renders components on the server and client. This changes i18n in important ways:

Server-side translation loading: In client-side React apps, translations are loaded by the browser. In Remix, translations can be loaded in loader functions and passed to components—meaning the HTML that leaves the server is already translated.

URL-based routing: Remix's file-based routing maps naturally to locale-based URL patterns like /en/products and /fr/products.

Progressive enhancement: Remix is designed to work without JavaScript. Your localized routes should render correctly without client-side hydration.

Nested routing: Remix's nested layouts mean locale detection and translation context can be established high in the route tree and inherited by child routes.

Setting Up remix-i18next

remix-i18next is built on top of i18next and provides Remix-specific utilities for server-side and client-side i18n.

Installation

npm install remix-i18next i18next react-i18next i18next-browser-languagedetector i18next-http-backend

Project Structure

app/
├── locales/
│   ├── en/
│   │   ├── common.json
│   │   └── home.json
│   └── fr/
│       ├── common.json
│       └── home.json
├── i18n.server.ts       # Server-side i18n config
├── i18n.client.ts       # Client-side i18n config
├── i18next.server.ts    # remix-i18next instance
└── root.tsx

Server-Side Configuration

// app/i18n.server.ts
import { RemixI18Next } from 'remix-i18next';
import i18n from './i18n';
import Backend from 'i18next-fs-backend';
import { resolve } from 'node:path';

const i18next = new RemixI18Next({
  detection: {
    supportedLanguages: i18n.supportedLngs,
    fallbackLanguage: i18n.fallbackLng,
  },
  i18next: {
    ...i18n,
    backend: {
      loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
    },
  },
  plugins: [Backend],
});

export default i18next;
// app/i18n.ts - shared configuration
export default {
  supportedLngs: ['en', 'fr', 'de', 'ja', 'ar'],
  fallbackLng: 'en',
  defaultNS: 'common',
  react: { useSuspense: false },
};

Root Route Setup

// app/root.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import {
  Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData,
} from '@remix-run/react';
import { useTranslation } from 'react-i18next';
import { useChangeLanguage } from 'remix-i18next';
import i18next from '~/i18next.server';
import i18n from '~/i18n';

export async function loader({ request }: LoaderFunctionArgs) {
  const locale = await i18next.getLocale(request);
  return json({ locale });
}

export let handle = { i18n: 'common' };

export default function App() {
  const { locale } = useLoaderData<typeof loader>();
  const { i18n } = useTranslation();

  useChangeLanguage(locale);

  return (
    <html lang={locale} dir={i18n.dir()}>
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Client-Side Initialization

// app/entry.client.tsx
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { RemixBrowser } from '@remix-run/react';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getInitialNamespaces } from 'remix-i18next';
import i18n from '~/i18n';

async function hydrate() {
  await i18next
    .use(initReactI18next)
    .use(LanguageDetector)
    .use(Backend)
    .init({
      ...i18n,
      ns: getInitialNamespaces(),
      backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' },
      detection: {
        order: ['htmlTag'],
        caches: [],
      },
    });

  startTransition(() => {
    hydrateRoot(
      document,
      <StrictMode>
        <RemixBrowser />
      </StrictMode>
    );
  });
}

hydrate();

Server-Side Entry

// app/entry.server.tsx
import { createInstance } from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-fs-backend';
import { renderToReadableStream } from 'react-dom/server';
import { RemixServer } from '@remix-run/react';
import { resolve } from 'node:path';
import i18n from '~/i18n';
import i18next from '~/i18next.server';

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const instance = createInstance();
  const lng = await i18next.getLocale(request);
  const ns = i18next.getRouteNamespaces(remixContext);

  await instance
    .use(initReactI18next)
    .use(Backend)
    .init({
      ...i18n,
      lng,
      ns,
      backend: {
        loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
      },
    });

  const body = await renderToReadableStream(
    <I18nextProvider i18n={instance}>
      <RemixServer context={remixContext} url={request.url} />
    </I18nextProvider>,
    {
      onError(error) { responseStatusCode = 500; },
    }
  );

  responseHeaders.set('Content-Type', 'text/html');
  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

URL-Based Locale Routing

A common pattern is encoding the locale in the URL: /en/products, /fr/produits. In Remix, implement this with a layout route:

app/routes/
├── ($locale).tsx          # Layout route handling locale prefix
├── ($locale).index.tsx    # Home page
├── ($locale).products.tsx # Products page
└── ($locale).blog.$slug.tsx # Blog post
// app/routes/($locale).tsx
import { json, redirect, type LoaderFunctionArgs } from '@remix-run/node';
import { Outlet, useLoaderData } from '@remix-run/react';

const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'ja', 'ar'];

export async function loader({ params, request }: LoaderFunctionArgs) {
  const { locale } = params;

  // No locale prefix: detect and redirect
  if (!locale) {
    const acceptLanguage = request.headers.get('Accept-Language') ?? 'en';
    const preferredLocale = parseAcceptLanguage(acceptLanguage, SUPPORTED_LOCALES);
    return redirect(`/${preferredLocale}`);
  }

  // Invalid locale: 404
  if (!SUPPORTED_LOCALES.includes(locale)) {
    throw new Response('Not Found', { status: 404 });
  }

  return json({ locale });
}

function parseAcceptLanguage(header: string, supported: string[]): string {
  // Parse Accept-Language header and return best match
  const languages = header
    .split(',')
    .map(lang => lang.trim().split(';')[0].trim().split('-')[0].toLowerCase());

  for (const lang of languages) {
    if (supported.includes(lang)) return lang;
  }
  return supported[0];
}

export default function LocaleLayout() {
  return <Outlet />;
}

Using Translations in Route Components

// app/routes/($locale).products.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { useTranslation } from 'react-i18next';
import i18next from '~/i18next.server';

export let handle = { i18n: ['common', 'products'] };

export async function loader({ request }: LoaderFunctionArgs) {
  const t = await i18next.getFixedT(request, 'products');

  // Use server-side t() for SEO-critical content
  const title = t('page.title');
  const description = t('page.description');

  return json({ title, description });
}

export function meta({ data }: MetaArgs) {
  return [
    { title: data?.title },
    { name: 'description', content: data?.description },
  ];
}

export default function ProductsPage() {
  const { t } = useTranslation('products');
  const { t: tc } = useTranslation('common');

  return (
    <main>
      <h1>{t('page.title')}</h1>
      <p>{t('page.description')}</p>
      <a href="/">{tc('nav.home')}</a>
    </main>
  );
}

Language Switcher Component

// app/components/LanguageSwitcher.tsx
import { Link, useLocation, useParams } from '@remix-run/react';

const LANGUAGES = [
  { code: 'en', label: 'English' },
  { code: 'fr', label: 'Français' },
  { code: 'de', label: 'Deutsch' },
  { code: 'ja', label: '日本語' },
  { code: 'ar', label: 'العربية' },
];

export function LanguageSwitcher() {
  const { locale = 'en' } = useParams();
  const { pathname } = useLocation();

  // Replace current locale in path
  const getLocalePath = (newLocale: string) => {
    return pathname.replace(`/${locale}`, `/${newLocale}`);
  };

  return (
    <nav aria-label="Language selection">
      {LANGUAGES.map(({ code, label }) => (
        <Link
          key={code}
          to={getLocalePath(code)}
          aria-current={code === locale ? 'page' : undefined}
          hrefLang={code}
        >
          {label}
        </Link>
      ))}
    </nav>
  );
}

RTL Support

For Arabic and Hebrew, set the dir attribute on the <html> element:

// app/root.tsx
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];

export default function App() {
  const { locale } = useLoaderData<typeof loader>();
  const { i18n } = useTranslation();
  const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';

  return (
    <html lang={locale} dir={dir}>
      {/* ... */}
    </html>
  );
}

See RTL support in CSS and React for detailed CSS implementation.

SEO Considerations

For multilingual Remix apps, SEO requires:

Alternate hreflang tags in the <head>:

export function meta({ data, location }: MetaArgs) {
  const baseUrl = 'https://example.com';
  const pathWithoutLocale = location.pathname.replace(/^\/(en|fr|de|ja|ar)/, '');

  return [
    { title: data?.title },
    // hreflang alternate links
    { tagName: 'link', rel: 'alternate', hrefLang: 'en', href: `${baseUrl}/en${pathWithoutLocale}` },
    { tagName: 'link', rel: 'alternate', hrefLang: 'fr', href: `${baseUrl}/fr${pathWithoutLocale}` },
    { tagName: 'link', rel: 'alternate', hrefLang: 'x-default', href: `${baseUrl}/en${pathWithoutLocale}` },
  ];
}

For comprehensive localization SEO strategies, see localization SEO.

Pluralization in Remix

i18next handles pluralization via the _one, _other, _zero key suffixes:

// public/locales/en/common.json
{
  "items_one": "{{count}} item",
  "items_other": "{{count}} items",
  "items_zero": "No items"
}
const { t } = useTranslation();
t('items', { count: 0 });   // "No items"
t('items', { count: 1 });   // "1 item"
t('items', { count: 42 });  // "42 items"

For languages with complex plural rules (Russian has 4 forms, Arabic has 6), i18next uses CLDR plural rules automatically. See pluralization rules across languages for details.

Performance Optimization

Namespace splitting: Load only the namespaces needed per route using the handle.i18n export. Large translation bundles slow down initial load.

Static generation: For content-heavy sites, use Remix's static generation or Cloudflare Pages to serve pre-rendered localized pages.

Translation caching: In production, cache translation files at the CDN level. Translation files rarely change and are good candidates for aggressive caching.

Lazy loading namespaces: Load supplementary namespaces lazily on the client for content that's not above the fold.


Take your app global with better-i18n

better-i18n combines AI-powered translations, git-native workflows, and global CDN delivery into one developer-first platform. Stop managing spreadsheets and start shipping in every language.

Get started free → · Explore features · Read the docs