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:
- IP address geolocation
- The
Accept-Languageheader orNavigator.languagesAPI - 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:
- Distinct domains (
hello.es,hello.jp) - Query parameters (
hello.com?loc=de) - 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:
- Folder-level translation organizes localized content into separate folders.
- Field-level translation translates individual field types.
- 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.
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.
- Internationalization — Storyblok Docs
- Video version of this article
- Remix docs
- remix-i18next
- Storyblok docs




