IntlPull
Tutorial
11 min read

Remix i18n: Server-Side Localization Done Right in 2026

Master Remix internationalization with remix-i18next for server-side translations. Learn loader-based i18n, route detection, SSR hydration, nested routes, and IntlPull integration for production apps.

IntlPull Team
IntlPull Team
Feb 12, 2026
On this page
Summary

Master Remix internationalization with remix-i18next for server-side translations. Learn loader-based i18n, route detection, SSR hydration, nested routes, and IntlPull integration for production apps.

Remix's server-side rendering architecture provides a unique advantage for internationalization: translations can be loaded on the server before the page renders, eliminating flash-of-untranslated-content (FOUC) and improving SEO. With remix-i18next, you get seamless i18n integration that leverages Remix's loader system for efficient translation delivery, automatic locale detection from URLs or cookies, and hydration without client-server mismatches. This comprehensive guide covers loader-based translations, route-based locale detection, nested route handling, cookie persistence, SEO optimization, and integration with IntlPull for automated translation management and team collaboration.

Understanding Remix i18n Architecture

Remix i18n differs from client-side approaches in three key ways:

  1. Loader-Based Loading: Translations load in route loaders, available before rendering
  2. Server-First Hydration: Server renders with translations, client hydrates without flicker
  3. Cookie/URL Detection: Locale persisted via cookies or URL prefixes (/es/about)

This eliminates the common "English-then-Spanish" flash seen in client-side i18n and improves Core Web Vitals (CLS, LCP) since content doesn't shift after load.

Basic Setup with remix-i18next

Step 1: Install Dependencies

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

Step 2: Create Translation Files

Create public/locales/en/common.json:

JSON
1{
2  "welcome": "Welcome to {{appName}}",
3  "nav": {
4    "home": "Home",
5    "about": "About",
6    "contact": "Contact"
7  },
8  "footer": {
9    "copyright": "© 2026 All rights reserved"
10  }
11}

Create public/locales/es/common.json:

JSON
1{
2  "welcome": "Bienvenido a {{appName}}",
3  "nav": {
4    "home": "Inicio",
5    "about": "Acerca de",
6    "contact": "Contacto"
7  },
8  "footer": {
9    "copyright": "© 2026 Todos los derechos reservados"
10  }
11}

Step 3: Configure i18next Server

Create app/i18n.server.ts:

TypeScript
1import { RemixI18Next } from 'remix-i18next';
2import i18nextOptions from './i18next-options';
3import Backend from 'i18next-fs-backend';
4import { resolve } from 'node:path';
5
6export const i18next = new RemixI18Next({
7  detection: {
8    supportedLanguages: i18nextOptions.supportedLngs,
9    fallbackLanguage: i18nextOptions.fallbackLng,
10    // Cookie-based locale detection
11    cookie: {
12      name: 'i18next',
13      sameSite: 'lax',
14      path: '/',
15      secure: process.env.NODE_ENV === 'production',
16      httpOnly: true,
17    },
18  },
19  i18next: {
20    ...i18nextOptions,
21    backend: {
22      loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
23    },
24  },
25  backend: Backend,
26});

Create app/i18next-options.ts:

TypeScript
1export default {
2  supportedLngs: ['en', 'es', 'fr'],
3  fallbackLng: 'en',
4  defaultNS: 'common',
5  react: { useSuspense: false },
6};

Step 4: Configure i18next Client

Create app/i18n.client.ts:

TypeScript
1import i18next from 'i18next';
2import { initReactI18next } from 'react-i18next';
3import LanguageDetector from 'i18next-browser-languagedetector';
4import Backend from 'i18next-http-backend';
5import i18nextOptions from './i18next-options';
6
7i18next
8  .use(initReactI18next)
9  .use(LanguageDetector)
10  .use(Backend)
11  .init({
12    ...i18nextOptions,
13    backend: {
14      loadPath: '/locales/{{lng}}/{{ns}}.json',
15    },
16    detection: {
17      order: ['cookie', 'navigator'],
18      caches: ['cookie'],
19    },
20  });
21
22export default i18next;

Step 5: Add Locale Detection to Root Loader

Update app/root.tsx:

TypeScript
1import { json, LoaderFunctionArgs } from '@remix-run/node';
2import {
3  Links,
4  LiveReload,
5  Meta,
6  Outlet,
7  Scripts,
8  ScrollRestoration,
9  useLoaderData,
10} from '@remix-run/react';
11import { useChangeLanguage } from 'remix-i18next';
12import { i18next } from './i18n.server';
13
14export async function loader({ request }: LoaderFunctionArgs) {
15  const locale = await i18next.getLocale(request);
16  return json({ locale });
17}
18
19export let handle = {
20  i18n: 'common',
21};
22
23export default function App() {
24  const { locale } = useLoaderData<typeof loader>();
25  useChangeLanguage(locale);
26
27  return (
28    <html lang={locale}>
29      <head>
30        <meta charSet="utf-8" />
31        <meta name="viewport" content="width=device-width, initial-scale=1" />
32        <Meta />
33        <Links />
34      </head>
35      <body>
36        <Outlet />
37        <ScrollRestoration />
38        <Scripts />
39        <LiveReload />
40      </body>
41    </html>
42  );
43}

Step 6: Use Translations in Routes

Create app/routes/_index.tsx:

TypeScript
1import { json } from '@remix-run/node';
2import { useLoaderData } from '@remix-run/react';
3import { useTranslation } from 'react-i18next';
4import { i18next } from '~/i18n.server';
5
6export async function loader({ request }) {
7  const t = await i18next.getFixedT(request, 'common');
8  return json({
9    meta: {
10      title: t('welcome', { appName: 'MyApp' }),
11    },
12  });
13}
14
15export let handle = {
16  i18n: 'common',
17};
18
19export default function Index() {
20  const { t } = useTranslation('common');
21  const { meta } = useLoaderData<typeof loader>();
22
23  return (
24    <div>
25      <h1>{t('welcome', { appName: 'MyApp' })}</h1>
26      <nav>
27        <a href="/">{t('nav.home')}</a>
28        <a href="/about">{t('nav.about')}</a>
29        <a href="/contact">{t('nav.contact')}</a>
30      </nav>
31    </div>
32  );
33}

Route-Based Locale Detection

URL Prefix Strategy

Implement /es/about style routes:

Create app/routes/($lang)._index.tsx:

TypeScript
1import { LoaderFunctionArgs, redirect } from '@remix-run/node';
2import { i18next } from '~/i18n.server';
3
4export async function loader({ request, params }: LoaderFunctionArgs) {
5  const locale = params.lang || (await i18next.getLocale(request));
6
7  // Validate locale
8  if (!['en', 'es', 'fr'].includes(locale)) {
9    return redirect('/en');
10  }
11
12  // Set cookie
13  const headers = new Headers();
14  headers.append('Set-Cookie', await i18next.serialize(locale));
15
16  return json({ locale }, { headers });
17}

Create app/routes/($lang).about.tsx:

TypeScript
1export default function About() {
2  const { t } = useTranslation('about');
3  return <h1>{t('title')}</h1>;
4}
5
6export let handle = {
7  i18n: 'about',
8};

Benefits:

  • SEO-friendly URLs (/es/contacto vs /contact?lang=es)
  • Shareable localized links
  • No JavaScript required for locale detection

Set locale cookie on language switch:

TypeScript
1import { ActionFunctionArgs, redirect } from '@remix-run/node';
2import { i18next } from '~/i18n.server';
3
4export async function action({ request }: ActionFunctionArgs) {
5  const formData = await request.formData();
6  const locale = formData.get('locale') as string;
7
8  return redirect(request.headers.get('Referer') || '/', {
9    headers: {
10      'Set-Cookie': await i18next.serialize(locale),
11    },
12  });
13}

Language Switcher Component:

TypeScript
1import { Form } from '@remix-run/react';
2
3export function LanguageSwitcher() {
4  const { i18n } = useTranslation();
5
6  return (
7    <Form method="post" action="/api/set-language">
8      <select
9        name="locale"
10        defaultValue={i18n.language}
11        onChange={(e) => e.currentTarget.form?.requestSubmit()}
12      >
13        <option value="en">English</option>
14        <option value="es">Español</option>
15        <option value="fr">Français</option>
16      </select>
17    </Form>
18  );
19}

Nested Routes and Namespaces

Route-Specific Translations

Split translations by route:

public/locales/
├── en/
│   ├── common.json (nav, footer)
│   ├── home.json (homepage)
│   ├── about.json (about page)
│   └── checkout.json (checkout flow)
└── es/
    ├── common.json
    ├── home.json
    └── ...

Load Multiple Namespaces:

TypeScript
1export let handle = {
2  i18n: ['common', 'checkout'],
3};
4
5export default function Checkout() {
6  const { t } = useTranslation(['checkout', 'common']);
7
8  return (
9    <>
10      <h1>{t('checkout:title')}</h1>
11      <footer>{t('common:footer.copyright')}</footer>
12    </>
13  );
14}

Parent-Child Route Inheritance

Parent layout loads shared namespace:

TypeScript
1// app/routes/dashboard.tsx
2export let handle = {
3  i18n: ['common', 'dashboard'],
4};
5
6// app/routes/dashboard.settings.tsx
7export let handle = {
8  i18n: ['settings'], // Inherits common + dashboard
9};

SEO Optimization

Hreflang Tags

Add alternate language links:

TypeScript
1import { MetaFunction } from '@remix-run/node';
2
3export const meta: MetaFunction<typeof loader> = ({ data }) => {
4  return [
5    { title: data.meta.title },
6    { name: 'description', content: data.meta.description },
7    { tagName: 'link', rel: 'alternate', hreflang: 'en', href: 'https://example.com/en/about' },
8    { tagName: 'link', rel: 'alternate', hreflang: 'es', href: 'https://example.com/es/acerca' },
9    { tagName: 'link', rel: 'alternate', hreflang: 'x-default', href: 'https://example.com/en/about' },
10  ];
11};

Server-Side Meta Tags

Generate translated meta in loader:

TypeScript
1export async function loader({ request }: LoaderFunctionArgs) {
2  const t = await i18next.getFixedT(request, 'meta');
3
4  return json({
5    meta: {
6      title: t('about.title'),
7      description: t('about.description'),
8      ogTitle: t('about.ogTitle'),
9    },
10  });
11}

IntlPull CLI Integration

Installation

Terminal
npm install -g @intlpullhq/cli
cd my-remix-app
intlpull init --framework remix

Configuration (intlpull.config.json):

JSON
1{
2  "projectId": "your-project-id",
3  "apiKey": "ip_live_...",
4  "framework": "remix",
5  "sourcePath": "public/locales",
6  "format": "json",
7  "languages": ["en", "es", "fr"],
8  "defaultLanguage": "en",
9  "namespaces": ["common", "home", "about", "checkout"]
10}

Automated Workflows

Terminal
1# Extract hardcoded strings from routes
2intlpull scan app/routes --auto-wrap
3
4# Push translations to IntlPull
5intlpull push
6
7# Pull latest translations
8intlpull pull
9
10# Watch for remote changes
11intlpull watch

GitHub Integration

Auto-sync translations on commits:

YAML
1# .github/workflows/i18n-sync.yml
2name: Sync Translations
3on:
4  push:
5    paths:
6      - 'public/locales/**'
7jobs:
8  sync:
9    runs-on: ubuntu-latest
10    steps:
11      - uses: actions/checkout@v3
12      - run: npm install -g @intlpullhq/cli
13      - run: intlpull push
14        env:
15          INTLPULL_API_KEY: ${{ secrets.INTLPULL_API_KEY }}

Testing Localization

Unit Tests

TypeScript
1import { createRemixStub } from '@remix-run/testing';
2import { render, screen } from '@testing-library/react';
3import Index from '~/routes/_index';
4
5test('renders welcome message', async () => {
6  const RemixStub = createRemixStub([
7    {
8      path: '/',
9      Component: Index,
10      loader() {
11        return { locale: 'en' };
12      },
13    },
14  ]);
15
16  render(<RemixStub />);
17  expect(await screen.findByText(/Welcome/i)).toBeInTheDocument();
18});

Integration Tests with Playwright

TypeScript
1import { test, expect } from '@playwright/test';
2
3test('switches language', async ({ page }) => {
4  await page.goto('/');
5  await page.selectOption('select[name="locale"]', 'es');
6  await expect(page.locator('h1')).toContainText('Bienvenido');
7});

Best Practices

1. Preload Translations in Loaders

Avoid client-side fetches:

TypeScript
1export async function loader({ request }) {
2  const locale = await i18next.getLocale(request);
3  const translations = await i18next.getFixedT(request, ['common', 'home']);
4
5  return json({ locale, translations });
6}

2. Use getFixedT for Server Rendering

TypeScript
const t = await i18next.getFixedT(request, 'common');
const title = t('welcome'); // Server-side translation

3. Validate Locale in Middleware

TypeScript
1export function middleware({ request }: LoaderFunctionArgs) {
2  const url = new URL(request.url);
3  const locale = url.pathname.split('/')[1];
4
5  if (!['en', 'es', 'fr'].includes(locale)) {
6    return redirect('/en' + url.pathname);
7  }
8}

4. Cache Translations in Production

TypeScript
1export async function loader({ request }: LoaderFunctionArgs) {
2  return json(
3    { locale },
4    {
5      headers: {
6        'Cache-Control': 'public, max-age=3600',
7      },
8    }
9  );
10}

Common Pitfalls

Issue: Hydration Mismatch

Cause: Server renders one locale, client hydrates with another.

Solution: Ensure locale detection is consistent server/client:

TypeScript
1// Use cookie, not browser detection
2export const i18next = new RemixI18Next({
3  detection: {
4    order: ['cookie'], // Don't use 'navigator' server-side
5  },
6});

Issue: Translations Not Loading

Cause: Missing handle.i18n export.

Solution:

TypeScript
export let handle = {
  i18n: 'common', // Required
};

Production Deployment Checklist

  • All routes have handle.i18n exports
  • Locale detection cookie configured
  • SEO hreflang tags added
  • Translation files validated
  • IntlPull CI/CD integration tested
  • Cache headers set for loaders
  • 404 page translated
  • Fallback locale set to en

Frequently Asked Questions

How do I handle dynamic routes like /blog/:slug?

Translate slugs in loader:

TypeScript
1export async function loader({ params, request }) {
2  const t = await i18next.getFixedT(request);
3  const slug = t(`slugs.${params.slug}`);
4  return json({ slug });
5}

Can I use Remix with headless CMS?

Yes, fetch translations from CMS in loader:

TypeScript
1export async function loader({ request }) {
2  const locale = await i18next.getLocale(request);
3  const content = await cms.getContent({ locale });
4  return json({ content });
5}

How do I avoid loading all translations upfront?

Use dynamic imports:

TypeScript
const translations = await import(`../locales/${locale}/${namespace}.json`);

Does IntlPull support Remix?

Yes, IntlPull CLI auto-detects Remix projects and configures public/locales paths.

How do I test different locales locally?

Set cookie manually:

Terminal
document.cookie = "i18next=es"

Conclusion

Remix's server-first architecture makes it the ideal framework for performant, SEO-friendly internationalization. By loading translations in loaders, persisting locale in cookies, and leveraging route-based detection, you eliminate FOUC and improve Core Web Vitals while providing a seamless multilingual experience. Integration with IntlPull streamlines translation management, enabling automated workflows and team collaboration without sacrificing performance or developer experience.

Start with the basics (remix-i18next + loaders), adopt route-based locale detection for SEO, and integrate IntlPull CLI for automated translation management. Your global users will appreciate the fast, localized experience, and your team will appreciate the simplified workflow.

Ready to ship your Remix app globally? Try IntlPull free with 500 keys and 3 languages, or explore our Remix documentation for advanced SSR patterns.

Tags
remix
i18n
localization
server-side
react
ssr
routing
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.