Why Localization Deserves Engineering Attention

Digital content reaches more than five billion people, yet the majority of them do not consume it in English. While accessibility, performance, and developer experience dominate technical discussions, internationalization remains comparatively under-discussed. The statistics tell a clear story: nearly three-quarters of internet users access content in languages other than English, and Asia accounts for the largest share of global users. Ignoring localization means excluding a significant portion of your potential audience from a usable experience.

Before exploring implementation, it helps to clarify two related terms:

  • i18n (internationalization): The architectural work that prepares an application to support multiple languages. This includes structures, features, and workflows that enable content to be translated.
  • l10n (localization): The act of translating content for users in specific regions. This process depends on the infrastructure provided by i18n.

How Language Detection Works

Regardless of the technology stack, applications determine user language through three primary mechanisms:

  1. IP address geolocation
  2. The Accept-Language header or Navigator.languages API
  3. Identifiers embedded in URLs

IP-based detection aligns content with the user's region, but it fails when a user's language preference differs from their physical location. It also creates SEO complications, as search engines may have trouble crawling location-based sites. The Accept-Language header and Navigator.languages communicate language preference reliably but provide no regional context.

URL identifiers offer the most user-friendly and SEO-conscious pattern. Three common structures exist:

  1. Distinct domains (hello.es, hello.jp)
  2. Query parameters (hello.com?loc=de)
  3. Localized sub-directories (hello.com/es, hello.com/ja)

Localized sub-directories respect the same-origin policy, making them a strong choice for SEO.

Library Options for React-Based Projects

Developers need not build i18n infrastructure from scratch. Two mature libraries serve React and React-based frameworks well.

Format.js

Format.js is a modular collection of JavaScript libraries focused on formatting numbers, dates, and strings. It runs in both browsers and Node.js runtimes and integrates with frameworks like Vue and React, making it suitable for Remix projects.

i18next

i18next extends beyond standard i18n features, offering language detection, translation caching, and a plugin ecosystem. Because it is written in JavaScript, it works across web, mobile, and desktop applications.

Implementing i18n in Remix

Remix projects can adopt any React-compatible i18n library. Two approaches stand out for their fit with Remix's architecture: the remix-i18next library and a headless CMS as a multilingual content source.

Using remix-i18next

remix-i18next, created by Remix contributor Sergio Xalambrí, adapts i18next's capabilities to Remix conventions. It requires no external dependencies and is production-ready. The setup begins with installing the necessary npm packages:

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

Next, create JSON files containing translations for each language. Naming the files common.json establishes the namespace for the strings:

{
  "intro": "Hello everyone!"
}
{
  "intro": "Hola a todos!"
}

A configuration file called i18n.js holds settings used when initializing the i18n server. Additional options are documented in the official i18next configuration docs.

export default {
  supportedLngs: ["en", "es"],
  fallbackLng: "en",
  defaultNS: "common",
  // Disabling suspense is recommended
  react: { useSuspense: false },
};

The i18next.server.js file contains logic for the Remix backend, referenced from entry.server.jsx. This initializes a server-side i18n instance and points to the JSON translation files.

import Backend from "i18next-fs-backend";
    import { resolve } from "node:path";
    import { RemixI18Next } from "remix-i18next";
    import i18n from "~/i18n"; // The configuration file we created
    
    let i18next = new RemixI18Next({
      detection: {
        supportedLanguages: i18n.supportedLngs,
        fallbackLanguage: i18n.fallbackLng,
      },
      i18next: {
        ...i18n,
        backend: {
          loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
        },
      },
      backend: Backend,
    });
    
    export default i18next;

Client-side translation requires edits to entry.client.jsx. The application must wait for translations to load before hydration to preserve interactivity.

import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { getInitialNamespaces } from "remix-i18next";
import i18n from "./i18n"; // The configuration file we created

i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(Backend)
  .init({
    ...i18n, // The same config we created for the server
    ns: getInitialNamespaces(),
    backend: {
      loadPath: "/locales/{{lng}}/{{ns}}.json",
    },
    detection: {
      order: ["htmlTag"],
      caches: [],
    },
  })
  .then(() => {
    // After i18next init, hydrate the app
    hydrateRoot(
      document,
      // Wrap RemixBrowser in I18nextProvider
      <I18nextProvider i18n={i18next}>
        <RemixBrowser />
      </I18nextProvider>
    );
  });

Server-side logic goes into entry.server.jsx, where detecting the user's preferred language enables route-level redirects.

import { createInstance } from "i18next";
import Backend from "i18next-fs-backend";
import { resolve } from "node:path";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18next from "./i18next.server"; // The backend file we created
import i18n from "./i18n"; // The configuration file we created

...

export default async function handleRequest(
...
) {
  // We create a new instance of i18next
  let instance = createInstance();

  // We can detect the specific locale from each request
  let lng = await i18next.getLocale(request);
  // The namespaces the routes about to render wants to use
  let ns = i18next.getRouteNamespaces(remixContext);

  await instance
    .use(initReactI18next)
    .use(Backend)
    .init({
      ...i18n,// The config we created
      lng, // The locale we detected from the request
      ns,
      backend: {
        loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"),
      },
    });

  return new Promise((resolve, reject) => {
    ...

    let { pipe, abort } = renderToPipeableStream(
      
        {" "}
      ,
      ...
    );
    ...
  });
}

To make translations available throughout the app, edit the root.jsx file. The useChangeLanguage hook synchronizes the i18n instance with the locale detected by the loader. When the locale changes, i18next loads the correct translation set.

...

import { json } from "@remix-run/node";
import { useChangeLanguage } from "remix-i18next";
import { useTranslation } from "react-i18next";
import i18next from "~/i18next.server";

...

export let loader = async ({ request }) => {
  let locale = await i18next.getLocale(request);
  return json({ locale });
};

export let handle = {
  i18n: "common",
};

export default function App() {
  // Get the locale from the loader
  let { locale } = useLoaderData();
  let { i18n } = useTranslation();

  useChangeLanguage(locale);

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

With the instance in place, any route can translate content using the t() function, which reads from the namespaces defined in the JSON files:

import { useTranslation } from "react-i18next";

export default function MyPage() {
  let { t } = useTranslation();
  return <h1>{t("intro")}</h1>;
}

The example above uses a single default namespace, but multiple namespaces are supported. For server-side translation, the getFixedT method works inside loaders and actions:

import i18next from "~/i18next.server";

...

export let loader = async ({ request }) => {
  let t = await i18next.getFixedT(request);
  let title = t("intro");
  return json({ title });
};

CMS-Led Localization With Remix

Source-level translation files cover one part of the i18n puzzle, but they do not give you localized URL identifiers. To address that, you can hand content management to a headless CMS. Storyblok, for example, offers three layouts for storing localized content and resolving language and region:

  1. Folder-level translation organizes localized content into separate folders.
  2. Field-level translation translates individual field types.
  3. Space-level translation dedicates entire spaces (environments or repositories) to one locale.

Folder-level translation is the approach that maps cleanly to URL identifiers. Each folder contains only the content for its locale, and you can change the localized slug directly from the folder settings. That slug then applies to every story inside the folder — so an about page in a Japanese folder can naturally appear at a localized path.

Folder Level Translation
(Large preview)
Folder settings
(Large preview)
Storyblok page general teaser
(Large preview)

To generate pages from those slugs, Remix provides Splats. A file named $.jsx acts as a catch-all route. Unlike dynamic segments, which stop at the next /, a splat captures every trailing segment of the path. For a URL like hello.com/ja/about/something, the splat route receives the entire remainder as a special parameter.

app
├── root.jsx
└── routes
    ├── files
    │   └── $.jsx
    └── files.jsx

That parameter becomes the key to resolving content. Inside $.jsx, you read the splat value and use it to fetch the matching localized story from the CMS.

export async function loader({ params }) {
  params["*"]; // "ja/about/something"
}
export default function Page() {
 // useLoaderData returns JSON parsed data from loader func
 let story = useLoaderData();
 story = useStoryblokState(story, {
   resolveRelations: ["featured-posts.posts", "selected-posts.posts"]
 });
 return <StoryblokComponent blok={story.content} />
};
// loader is Backend API & Wired up through useLoaderData
export const loader = async ({ params, preview = false }) => {
 let slug = params["*"] ?? "home";
 slug = slug.endsWith("/") ? slug.slice(0, -1) : slug;
 let sbParams = {
   version: "draft",
   resolve_relations: ["featured-posts.posts", "selected-posts.posts"],
 };
 // …
 let { data } = await getStoryblokApi().get(`cdn/stories/${slug}`,
 sbParams);
 return json(data?.story, preview);
};

Key Takeaways

i18n is not just a translation exercise — it directly influences SEO and UX. With Remix, you can implement it through translation files or by delegating content structure to a CMS. The folder-level approach gives you both clean URLs and a manageable content workflow, while splat routes keep the Remix side flexible for arbitrarily nested paths.

Smashing Editorial