---
createdAt: 2026-09-02
updatedAt: 2026-09-02
title: React Router i18n - locale routes that get indexed
description: How to add locale-prefixed routes in React Router v7, why the answer differs between framework mode and SPA mode, and how to avoid hydration mismatches.
keywords:
- react router i18n
- React Router v7
- locale routing
- hydration mismatch
- hreflang
- react-i18next
- Intlayer
slugs:
- blog
- i18n-technologies
- frameworks
- react-router
author: aymericzip
---
# React Router i18n: locale routes that actually get indexed
Adding a translation library to a React Router app is the easy part. The hard part is the routing: a `:locale` segment at the root of the tree, a redirect from `/` to the right language, and HTML that already contains the right words when a crawler reads it. This post covers the routing shape, why framework mode and SPA mode give different answers, and the hydration bug everyone hits once.
## Table of Contents
## The symptom
You wired up a translation library, the language switcher works, and then you check what Google sees:
```bash
curl -s https://example.com/fr/about | grep "
About us
```
The French page serves English HTML. The switcher works in the browser because the locale is resolved in a `useEffect`, after hydration. Crawlers, link previews and `curl` never get that far. This is a routing problem, not a translation problem, and no message library fixes it for you.
If you want the general React library comparison, that is [a different post](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/list_i18n_technologies/frameworks/react.md). This one is about React Router specifically.
## The routing shape
There is one correct shape, and every library agrees on it: put the locale as the first segment of the route tree, so it is a real URL, not a state.
```txt
/ -> redirect to the negotiated locale
/about -> default locale (or redirect, depending on your mode)
/fr/about -> French
/es/about -> Spanish
```
Two rules follow from it:
- **The URL is the source of truth.** A cookie or `navigator.language` may pick which URL to send someone to, but once you are on `/fr/about`, the page is French, no matter what the cookie says.
- **An unknown prefix is a 404, not a fallback.** If `/de/about` renders the English page with a 200, you have just created a duplicate of every page in your site for every string someone types.
The `/` redirect is where Accept-Language belongs. In framework mode it is a few lines in the root loader:
```ts fileName="app/root.tsx"
import { defaultLocale, getLocaleFromStorage, localeDetector } from "intlayer";
import { redirect } from "react-router";
import type { Route } from "./+types/root";
export const loader = ({ request }: Route.LoaderArgs) => {
const cookies = new Map(
(request.headers.get("cookie") ?? "")
.split("; ")
.map((pair) => pair.split("=") as [string, string])
);
const locale =
// 1. an explicit choice the visitor already made
getLocaleFromStorage({
getCookie: (name) => cookies.get(name) ?? null,
getHeader: (name) => request.headers.get(name),
}) ??
// 2. Accept-Language
localeDetector(Object.fromEntries(request.headers)) ??
// 3. the floor
defaultLocale;
return redirect(`/${locale}`);
};
```
`getLocaleFromStorage` only reads the sources you hand it, so skipping `getCookie` means a returning visitor's saved language is quietly ignored.
Redirect with a 302, not a 301. The negotiated locale depends on the visitor, and a permanent redirect gets cached by browsers and CDNs for the wrong one.
## Framework mode and SPA mode are not the same problem
React Router v7 absorbed Remix. Framework mode is the Remix half: a `react-router.config.ts`, loaders, and server rendering. Library mode is the old `react-router-dom`, mounted client-side into a static `index.html`. The i18n answer differs, and it is worth being blunt about it.
| | Framework mode (`ssr: true`) | Framework mode (`ssr: false`) | Library / SPA |
| :--------------------- | :--------------------------- | :------------------------------------------- | :------------------------------ |
| Where locale resolves | `loader`, per request | Build time, per prerendered route | Browser, after JS loads |
| First HTML byte | Correct language | Correct language if the route is prerendered | Whatever the bundle defaults to |
| `` | Correct | Correct | Patched after hydration |
| hreflang in the source | Yes | Yes | No |
**A pure SPA cannot do multilingual SEO properly.** Google will render JavaScript eventually, but it is a second pass, it is not guaranteed per URL, and other crawlers (Bing, social previews, LLM fetchers) mostly do not render at all. If your locale only exists after hydration, your localized URLs are, at best, indexed slowly and inconsistently.
If you are stuck in library mode, the realistic middle ground is prerendering. Framework mode with `ssr: false` plus a `prerender` list emits real HTML per locale at build time and still deploys as static files:
```ts fileName="react-router.config.ts"
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
prerender: ["/", "/about", "/fr", "/fr/about", "/es", "/es/about"],
} satisfies Config;
```
That works for a fixed set of pages. For thousands of URLs, or anything personalized, you need a server.
## Declaring the locale segment: config routes vs file-system routes
React Router v7 supports both. Config routes are explicit, in `app/routes.ts`:
```ts fileName="app/routes.ts"
import { route, type RouteConfig } from "@react-router/dev/routes";
export default [
route("/:locale?", "routes/page.tsx"),
route("/:locale?/about", "routes/about/page.tsx"),
] satisfies RouteConfig;
```
File-system routes encode the same thing in filenames, where `($locale)` is an optional dynamic segment and `.` separates path segments:
```txt
app/routes/
├── ($locale)._index.tsx # / and /fr
├── ($locale)._index.content.ts
├── ($locale).about.tsx # /about and /fr/about
└── ($locale).about.content.ts
```
One gotcha with `flatRoutes`: any file in `routes/` becomes a route, including colocated content files. You have to exclude them explicitly, or `about.content.ts` becomes a URL.
```ts fileName="app/routes.ts"
import { flatRoutes } from "@react-router/fs-routes";
import { configuration } from "intlayer";
export default flatRoutes({
ignoredRouteFiles: configuration.content.fileExtensions.map(
(extension) => `**/*${extension}`
),
});
```
Both variants are documented in full: [config routes](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/intlayer_with_react_router_v7.md) and [file-system routes](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/intlayer_with_react_router_v7_fs_routes.md).
## The hydration mismatch
This is the bug that costs an afternoon. The server renders `/fr/about` in French. The client boots, a provider reads `localStorage` or `navigator.language`, decides the user is English, and React throws a hydration error, then silently re-renders the whole tree.
The fix is an order of precedence, applied identically on both sides:
1. **The URL param.** If the route has a locale segment, it wins. Always.
2. **Cookie or storage.** Only consulted when the URL carries no locale, and only to decide where to redirect.
3. **`Accept-Language`.** Server-side only, as a last resort on `/`.
4. **Default locale.** The floor.
Concretely: the provider must be given the locale from the loader, not left to find it itself.
```tsx fileName="app/root.tsx"
export function Layout({ children }: { children: React.ReactNode }) {
const { locale } = useLoaderData() ?? {};
return (
{children}
);
}
```
`navigator.language` and `localStorage` do not exist on the server. Any code path that reads them during render, rather than inside an effect or a redirect decision, is a hydration mismatch waiting for its first non-English visitor.
## SEO: three things, none of them optional
**hreflang.** Every localized version of a page must point at every other one, including itself, plus an `x-default`. Emit it from the route's `links` export so it lands in the HTML:
```tsx fileName="app/routes/($locale).about.tsx"
import { getMultilingualUrls } from "intlayer";
export const links: Route.LinksFunction = () => {
const urls = getMultilingualUrls("https://example.com/about");
return Object.entries(urls).map(([locale, href]) => ({
rel: "alternate",
hrefLang: locale,
href,
}));
};
```
Full ruleset in the [hreflang guide](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/hreflang_guide_multilingual_seo.md).
**Canonical.** Self-referencing, per locale. `/fr/about` is canonical to itself, never to `/about`. Pointing localized pages at the default one tells Google to drop them.
**The switcher must be links.** A `