Internationalization (i18n) is key for reaching a global audience. But implementing a solid i18n routing strategy in React can be tricky, especially with a library like React Router. This post breaks down an advanced i18n routing solution using React Router v7 and i18next, showing how to build a seamless multilingual experience.

We’ll look at the source code of a real-world project to see how it handles i18n routing, from localized routes to server-side rendering (SSR) and client-side hydration with i18next.

The Challenge of i18n Routing

A good i18n routing system needs to be:

  • SEO-friendly: Search engines should be able to crawl and index all language versions of your pages.
  • User-friendly: Users should be able to switch languages easily and share localized URLs.
  • Developer-friendly: The system should be easy to maintain and scale.

This project achieves these goals by putting the language in the URL path (e.g., /en/about, /fr/a-propos) and using a combination of custom components and configuration to manage the complexity.

A Modular Approach to Route Definition

The foundation of this system is a modular approach to defining routes. Instead of mixing i18n and non-i18n routes, they are kept in separate files.

app/i18n-routes.ts

This file is the core of the i18n routing system. It defines the structure of internationalized routes with a custom I18nRoute type.

// app/i18n-routes.ts

export type I18nRoute = I18nLayoutRoute | I18nPageRoute;
export type I18nLayoutRoute = { file: string; children: I18nRoute[] };
export type I18nPageRoute = { id: string; file: string; paths: Record<Language, string> };

export const i18nRoutes = [
  {
    file: 'routes/layout.tsx',
    children: [
      {
        id: 'PROT-0001',
        file: 'routes/index.tsx',
        paths: { en: '/en/', fr: '/fr/' },
      },
      {
        id: 'EMPL-0001',
        file: 'routes/employee/index.tsx',
        paths: {
          en: '/en/employee',
          fr: '/fr/employe',
        },
      },
      // ... more routes
    ],
  },
] as const satisfies I18nRoute[];

This structure provides:

  • Type safety: The I18nRouteFile and I18nRouteId types are extracted from i18nRoutes, ensuring only valid route files and IDs are used.
  • Separation of concerns: i18n route definitions are separate from the main route configuration, making them easier to manage.
  • Flexibility: The I18nLayoutRoute and I18nPageRoute types allow for complex nested route structures.

Generating React Router Routes

The i18nRoutes array is used by app/routes.ts to generate the final React Router configuration.

app/routes.ts

This file’s toRouteConfigEntries function recursively transforms the i18nRoutes array into a RouteConfigEntry[] for React Router.

// app/routes.ts

import { layout, route } from '@react-router/dev/routes';
import { i18nRoutes, isI18nPageRoute } from './i18n-routes';

function i18nPageRoutes(i18nPageRoute: I18nPageRoute): RouteConfigEntry[] {
  return Object.entries(i18nPageRoute.paths).map(([language, path]) => {
    const id = `${i18nPageRoute.id}-${language.toUpperCase()}`;
    return route(path, i18nPageRoute.file, { id: id });
  });
}

export function toRouteConfigEntries(routes: I18nRoute[]): RouteConfigEntry[] {
  return routes.flatMap((currentRoute) => {
    return isI18nPageRoute(currentRoute)
      ? i18nPageRoutes(currentRoute)
      : layout(currentRoute.file, toRouteConfigEntries(currentRoute.children));
  });
}

export default [
  // ... other routes
  ...toRouteConfigEntries(i18nRoutes),
  // ... other routes
] satisfies RouteConfigEntry[];

This approach combines the power of React Router’s file-based routing with a centralized, type-safe i18n routing configuration.

With the routes defined, the next step is to create links that work with the i18n system. This is where the custom <AppLink> component comes in.

app/components/links.tsx

The <AppLink> component wraps React Router’s <Link> to add support for i18n routes.

// app/components/links.tsx

export function AppLink({
  children,
  disabled,
  hash,
  lang,
  params,
  file,
  search,
  to,
  ...props
}: AppLinkProps): JSX.Element {
  const { currentLanguage } = useLanguage();

  if (to !== undefined) {
    return (
      <Link lang={lang} to={to} {...props}>
        {children}
      </Link>
    );
  }

  const targetLanguage = lang ?? currentLanguage;

  // ... error handling

  const route = getRouteByFile(file, i18nRoutes);
  const pathname = generatePath(route.paths[targetLanguage], params);

  const langProp = targetLanguage !== currentLanguage ? targetLanguage : undefined;
  const reloadDocumentProp = props.reloadDocument ?? lang !== undefined;

  return (
    <Link lang={langProp} to={{ hash, pathname, search }} reloadDocument={reloadDocumentProp} {...props}>
      {children}
    </Link>
  );
}

Instead of a to prop, <AppLink> takes a file prop, which is a type-safe reference to a route file from i18nRoutes. It uses the useLanguage hook to get the current language and generates the correct localized URL.

This lets developers create links to i18n routes without worrying about the URL structure:

<AppLink file="routes/employee/profile/personal-information.tsx">
  Personal Information
</AppLink>

If the current language is English, this renders a link to /en/employee/profile/personal-information. If it’s French, it links to /fr/employe/profil/informations-personnelles.

Language Toggling with the LanguageSwitcher Component

A language switcher is a must-have for any i18n setup. This project uses a LanguageSwitcher component for that.

app/components/language-switcher.tsx

This component uses the hooks and components we’ve already seen to create a language toggle link.

// app/components/language-switcher.tsx

export function LanguageSwitcher({ className, children, ...props }: LanguageSwitcherProps) {
  const { altLanguage } = useLanguage();
  const { search } = useLocation();
  const { file } = useRoute();
  const params = useParams();

  return (
    <InlineLink
      className={cn('text-slate-700 sm:text-lg', className)}
      file={file as I18nRouteFile}
      lang={altLanguage}
      params={params}
      reloadDocument={true}
      search={search}
      {...props}
    >
      {children}
    </InlineLink>
  );
}

Here’s how it works:

  • useLanguage(): Provides the alternate language (altLanguage).
  • useRoute(): Identifies the current route’s file (file) to find the translated path in i18nRoutes.
  • useLocation() & useParams(): Preserve the current URL’s query and dynamic parameters.
  • <InlineLink>: Passes the collected information to <InlineLink> (which uses <AppLink>).
  • reloadDocument={true}: Forces a full page reload to ensure the server renders the page in the new language and loads the correct translations.

This makes it easy to add a context-aware language switcher anywhere in the app.

Server-Side Rendering (SSR) with i18next

This project uses one i18next instance per request for SSR, ensuring each user gets the correct translations.

app/entry.server.tsx

The server entrypoint creates a new i18next instance for each request.

// app/entry.server.tsx

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext,
  loadContext: AppLoadContext,
) {
  const language = getLanguage(request);
  const i18n = await initI18next(language);

  // ... render the app
}

app/i18n-config.server.ts

The server-side i18next configuration preloads all namespaces and languages, so all translations are available on the server.

// app/i18n-config.server.ts

export async function initI18next(language?: Language): Promise<i18n> {
  const { I18NEXT_DEBUG: debug } = serverEnvironment;
  const i18n = createInstance().use(initReactI18next);
  const namespaces = getNamespaces(i18nResources);

  await i18n.init({
    debug: debug,
    defaultNS: false,
    fallbackLng: 'en',
    lng: language,
    preload: ['en', 'fr'],
    supportedLngs: ['en', 'fr'],
    ns: namespaces,
    resources: i18nResources,
    interpolation: { escapeValue: false },
    // ...
  });

  return i18n;
}

Client-Side Hydration and i18next

On the client, the i18next instance is initialized with only the necessary namespaces for the current route to reduce the initial bundle size.

app/entry.client.tsx

The client entrypoint initializes i18next and hydrates the React app.

// app/entry.client.tsx

startTransition(() => {
  const routeModules = Object.values(globalThis.__reactRouterRouteModules);
  const routes = routeModules.filter((routeModule) => routeModule !== undefined);
  void initI18next(getI18nNamespace(routes)).then(hydrateDocument);
});

app/i18n-config.client.ts

The client-side i18next configuration uses i18next-fetch-backend to load translations on demand.

// app/i18n-config.client.ts

export async function initI18next(namespace: Namespace): Promise<i18n> {
  const { BUILD_REVISION, I18NEXT_DEBUG } = globalThis.__appEnvironment;

  const languageDetector = {
    type: 'languageDetector',
    detect: () => document.documentElement.lang,
  } satisfies LanguageDetectorModule;

  await i18Next
    .use(languageDetector)
    .use(initReactI18next)
    .use(I18NextFetchBackend)
    .init({
      debug: I18NEXT_DEBUG,
      ns: namespace,
      fallbackLng: 'en',
      defaultNS: false,
      preload: ['en', 'fr'],
      supportedLngs: ['en', 'fr'],
      backend: { loadPath: `/api/translations?ns=&lng=&v=${BUILD_REVISION}` },
      interpolation: { escapeValue: false },
    });

  return i18Next;
}

The /api/translations Endpoint

The /api/translations endpoint is a simple API route that serves the translation files to the client. It takes the language (lng) and namespace (ns) as query parameters and returns the corresponding translation bundle as a JSON object.

Here’s the implementation of the endpoint:

// app/routes/api/translations.ts

import type { Route } from './+types/translations';

import { serverDefaults } from '~/.server/environment';
import { HttpStatusCodes } from '~/errors/http-status-codes';
import { initI18next } from '~/i18n-config.server';

// we will aggressively cache the requested resource bundle for 1y
const CACHE_DURATION_SECS = 365 * 24 * 60 * 60;

export async function loader({ context, params, request }: Route.LoaderArgs) {
  const url = new URL(request.url);

  const language = url.searchParams.get('lng');
  const namespace = url.searchParams.get('ns');

  if (!language || !namespace) {
    return Response.json(
      { message: 'You must provide a language (lng) and namespace (ns)' },
      { status: HttpStatusCodes.BAD_REQUEST },
    );
  }

  const i18next = await initI18next();
  const resourceBundle = i18next.getResourceBundle(language, namespace);

  if (!resourceBundle) {
    return Response.json(
      { message: 'No resource bundle found for this language and namespace' },
      { status: HttpStatusCodes.NOT_FOUND },
    );
  }

  // cache if the requested revision is anything other
  // than the default build revision used during development
  const revision = url.searchParams.get('v') ?? serverDefaults.BUILD_REVISION;
  const shouldCache = revision !== serverDefaults.BUILD_REVISION;

  return Response.json(resourceBundle, {
    headers: shouldCache //
      ? { 'Cache-Control': `max-age=${CACHE_DURATION_SECS}, immutable` }
      : { 'Cache-Control': 'no-cache' },
  });
}

This endpoint uses the server-side i18next instance to retrieve the requested resource bundle. It also sets the Cache-Control header based on the build revision, ensuring that the translations are cached effectively.

Cache Busting

To ensure that users always have the latest translations after a new deployment, a cache-busting strategy is implemented. This is achieved by adding a version identifier to the translation file requests.

The i18n-config.client.ts file configures the i18next-fetch-backend to include a v query parameter in the request for translation files. The value of this parameter is the BUILD_REVISION.

// app/i18n-config.client.ts

export async function initI18next(namespace: Namespace): Promise<i18n> {
  const { BUILD_REVISION, I18NEXT_DEBUG } = globalThis.__appEnvironment;

  // ...

  await i18Next
    // ...
    .use(I18NextFetchBackend)
    .init({
      // ...
      backend: { loadPath: `/api/translations?ns=&lng=&v=${BUILD_REVISION}` },
      // ...
    });

  return i18Next;
}

The BUILD_REVISION is a unique identifier generated at build time and is made available to the client-side application through the globalThis.__appEnvironment object. When a new version of the application is deployed, the BUILD_REVISION changes, and the URL for the translation files also changes. This forces the browser to download the new translation files, ensuring that the user always sees the most up-to-date content.

Putting It All Together: A Request’s Journey

  1. A user requests a page, like /en/employee/profile.
  2. The server gets the request and detects the language (en) from the URL.
  3. A new i18next instance is created for the request with the language set to en.
  4. The React app is rendered to a stream with content translated into English.
  5. The client receives the HTML and starts to hydrate the app.
  6. The client-side i18next instance is initialized, detecting the language from the <html> tag’s lang attribute.
  7. The client fetches any other translations it needs from the /api/translations endpoint.
  8. The app is now fully interactive.

Conclusion

This project shows a powerful and scalable way to handle i18n routing in a modern React app. By combining a modular route definition, a custom <AppLink> component, and a well-designed i18next configuration, it provides a great experience for both users and developers.

This solution is a solid starting point for any project that needs to support multiple languages and can be adapted to your specific needs.