i18n routing with i18next and React Router v7
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
I18nRouteFileandI18nRouteIdtypes are extracted fromi18nRoutes, 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
I18nLayoutRouteandI18nPageRoutetypes 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.
The Magic of <AppLink>
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 ini18nRoutes.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
- A user requests a page, like
/en/employee/profile. - The server gets the request and detects the language (
en) from the URL. - A new i18next instance is created for the request with the language set to
en. - The React app is rendered to a stream with content translated into English.
- The client receives the HTML and starts to hydrate the app.
- The client-side i18next instance is initialized, detecting the language from the
<html>tag’slangattribute. - The client fetches any other translations it needs from the
/api/translationsendpoint. - 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.