このページとあなたの好きなAIアシスタントを使ってドキュメントを要約します
このページのコンテンツはAIを使用して翻訳されました。
英語の元のコンテンツの最新バージョンを見るこのドキュメントを改善するアイデアがある場合は、GitHubでプルリクエストを送信することで自由に貢献してください。
ドキュメントへのGitHubリンクドキュメントのMarkdownをクリップボードにコピー
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:
コードをクリップボードにコピー
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. 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.
コードをクリップボードにコピー
Two rules follow from it:
- The URL is the source of truth. A cookie or
navigator.languagemay 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/aboutrenders 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:
コードをクリップボードにコピー
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 |
<html lang> | 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:
コードをクリップボードにコピー
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:
コードをクリップボードにコピー
File-system routes encode the same thing in filenames, where ($locale) is an optional dynamic segment and . separates path segments:
コードをクリップボードにコピー
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.
コードをクリップボードにコピー
Both variants are documented in full: config routes and file-system routes.
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:
- The URL param. If the route has a locale segment, it wins. Always.
- Cookie or storage. Only consulted when the URL carries no locale, and only to decide where to redirect.
Accept-Language. Server-side only, as a last resort on/.- Default locale. The floor.
Concretely: the provider must be given the locale from the loader, not left to find it itself.
コードをクリップボードにコピー
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:
コードをクリップボードにコピー
Full ruleset in the hreflang guide.
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 <button onClick={setLocale}> is invisible to a crawler, so the alternate URLs are never discovered by following the page. Render <Link to={getLocalizedUrl(pathWithoutLocale, targetLocale)}> and keep the user on the same route instead of bouncing them to the home page.
The options
テーブルをモーダルで開き、すべてのデータを明確に表示
| Library | Locale resolution in loaders | Type-safe keys | Note |
|---|---|---|---|
react-i18next | Manual: one instance per request, or you leak locale between users | Opt-in via declaration merging | The default. Largest ecosystem, most Stack Overflow answers |
| Lingui | Manual, with an explicit i18n.activate per request | Yes, via extraction | ICU messages, compiled catalogs, small runtime |
| Intlayer | Built in: getIntlayer(key, params.locale) in loaders and meta | Generated, on by default | Build plugin required, smaller ecosystem |
The react-i18next trap in framework mode is worth naming: the default singleton i18n instance is module scope, which on a server is shared across concurrent requests. Two visitors on different locales at the same time can get each other's language. You need a request-scoped instance, which the library supports but does not do for you.
Intlayer with React Router v7
Content is declared in a file next to the route that renders it, and a Vite plugin compiles those declarations at build time.
コードをクリップボードにコピー
The route reads it in three places: loader for validation, meta for the tags, and the component for the body.
コードをクリップボードにコピー
validatePrefix is what turns an unknown prefix into a 404 instead of a duplicate page. getIntlayer is the synchronous read used outside React, which is what meta needs.
Setup is npx intlayer init, then intlayer() next to reactRouter() in vite.config.ts. If you do not want the locale in the URL at all, routing.mode also accepts "prefix-all", "no-prefix" and "search-params", documented in the configuration reference.
What it costs you. The build plugin is mandatory: no plugin, no dictionaries, so a plain tsc build or a test runner that bypasses Vite needs configuring. The ecosystem is much smaller than i18next's, and the project is young, so you will occasionally be the first person to hit a bug. ICU message format is still incomplete, which matters if your translation vendor ships ICU strings today.
On an existing react-i18next codebase, the compat adapter aliases react-i18next and i18next at the bundler level, so useTranslation, <Trans> and suffix plurals keep working while Intlayer serves the content. The migration guide covers moving off it afterwards.
Common mistakes
- Resolving the locale in a
useEffect. It works on screen and fails for every crawler and every link preview. - 301 on the
/redirect. The target is per-visitor, so a permanent redirect is cached for the wrong language. - Falling back instead of 404-ing on an unknown prefix.
/de/*returning 200 duplicates your entire site. - A module-scope i18n instance on the server. Concurrent requests share it. Scope it per request.
- Canonical pointing at the default locale. It asks Google to de-index the translations you just paid for.
- Forgetting
ignoredRouteFileswithflatRoutes. Colocated content files silently become routes.
Going further
- Set up Intlayer with React Router v7 config routes
- The file-system routes variant
- i18n library benchmark: bundle size and locale-switch timings
- Drop-in
react-i18nextcompat adapter and the full migration guide - i18n solutions for React, compared
- hreflang for multilingual SEO
- Internationalization and SEO in practice
- Custom URL rewrites for localized paths
コメント
まだコメントはありません。最初のコメントを共有しましょう。
