How to load translations from files in React Router v7
Separate translatable content from code
Problem
Hardcoding user-facing strings directly into components creates a tight coupling between content and code. Each new language requires developers to modify implementation files, extending conditional logic and increasing complexity. When copy changes, even minor wording adjustments demand code deployments. This approach makes translation workflows dependent on engineering cycles and prevents non-technical team members from managing content independently.
As applications grow, scattered string literals become difficult to track and maintain. Finding every occurrence of a phrase across a codebase is error-prone, and ensuring consistency across similar messages becomes nearly impossible without a centralized source of truth.
Solution
Extract all translatable strings into external JSON files organized by language, with one file per locale. Replace hardcoded strings in components with message identifiers that reference entries in these files. At runtime, the application loads the appropriate translation file based on the user's locale and provides those messages to the internationalization library, which resolves identifiers to their translated values.
This separation allows translators to work directly with JSON files without touching code, enables content updates through simple file changes, and provides a single source of truth for each language's strings.
Steps
1. Create translation files for each locale
Organize translation files in a dedicated directory, with one JSON file per language. Structure each file as a flat object mapping message identifiers to translated strings.
{
"welcome.title": "Welcome back",
"welcome.subtitle": "Continue where you left off",
"nav.home": "Home",
"nav.about": "About",
"nav.contact": "Contact"
}
Save this as app/translations/en.json for English, then create parallel files like app/translations/es.json and app/translations/fr.json with translations for other languages. Use consistent keys across all files so the same identifier resolves to the appropriate translation in each locale.
2. Load translations in a route loader
Use a route loader to fetch the translation file for the current locale before rendering. This ensures messages are available when components mount.
import type { Route } from "./+types/root";
import enMessages from "./translations/en.json";
import esMessages from "./translations/es.json";
import frMessages from "./translations/fr.json";
const messages: Record<string, Record<string, string>> = {
en: enMessages,
es: esMessages,
fr: frMessages,
};
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const locale = url.searchParams.get("locale") || "en";
return {
locale,
messages: messages[locale] || messages.en,
};
}
The loader reads the locale from the URL query parameter and returns both the locale and its corresponding messages. Components can access this data through loaderData to configure the internationalization provider.
3. Configure the IntlProvider with loaded messages
Wrap your application with IntlProvider from react-intl, passing the locale and messages from the loader data.
import { IntlProvider } from "react-intl";
import { Outlet } from "react-router";
import type { Route } from "./+types/root";
export default function Root({ loaderData }: Route.ComponentProps) {
return (
<IntlProvider locale={loaderData.locale} messages={loaderData.messages}>
<html lang={loaderData.locale}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<Outlet />
</body>
</html>
</IntlProvider>
);
}
The IntlProvider makes the locale and messages available to all descendant components through React context. Child routes render through the Outlet and inherit access to the translation data.
4. Reference messages by identifier in components
Replace hardcoded strings with FormattedMessage components that reference message identifiers from your translation files.
import { FormattedMessage } from "react-intl";
export default function Welcome() {
return (
<div>
<h1>
<FormattedMessage id="welcome.title" />
</h1>
<p>
<FormattedMessage id="welcome.subtitle" />
</p>
<nav>
<a href="/">
<FormattedMessage id="nav.home" />
</a>
<a href="/about">
<FormattedMessage id="nav.about" />
</a>
<a href="/contact">
<FormattedMessage id="nav.contact" />
</a>
</nav>
</div>
);
}
Each FormattedMessage component looks up its id in the messages object provided by IntlProvider and renders the corresponding translated string. When the locale changes and the loader runs again with different messages, all components automatically display the new translations without code changes.