はじめに
React Router v7がリリースされました🎉
また、remix-i18next も React Router v7対応されていたので、これらを組み合わせてパスルーティングなi18nをやってみました。
パスルーティングなi18nとは、パスに
/en
や
/ja
が入ることで、言語を指定する方法の国際化対応です。今回はそれに加えて、言語が指定されていない場合にブラウザの言語に合わせて適切な言語切り替えができるようにしていきます。
remix-i18nextの導入
まずは、remix-i18nextを導入します。基本的には、remix-i18nのREADME.mdに沿って進めつつ、パスルーティングに必要な実装もしていきます。
インストール
まずは、以下のコマンドでパッケージを追加します。
1 | pnpnm add remix-i18next i18next react-i18next i18next-browser-languagedetector i18next-http-backend i18next-fs-backend |
パッケージが色々とありますが、全て必要なものです!
セットアップ
まず、翻訳ファイルを作成します。
public/locales/ja/common.json
:
1 2 3 4 | { "greeting": "こんにちは", "subpage": "これはサブページです" } |
public/locales/en/common.json
:
1 2 3 4 | { "greeting": "Hello", "subpage": "This is a subpage" } |
次に、i18nextの設定ファイルを作成します。今回は
app/i18n.ts
に作成します。
1 2 3 4 5 | export default { supportedLngs: ["en", "ja"], fallbackLng: "en", defaultNS: "common", }; |
そして、Remix i18nextを使用するためのファイルを
app/i18next.server.ts
に作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | import { resolve } from "node:path"; import Backend from "i18next-fs-backend"; import { RemixI18Next } from "remix-i18next/server"; import i18n from "~/i18n"; // your i18n configuration file const i18next = new RemixI18Next({ detection: { supportedLanguages: i18n.supportedLngs, fallbackLanguage: i18n.fallbackLng, // パスから言語を取得するためのカスタム実装 async findLocale(request) { const { pathname } = new URL(request.url); // pathnameには /ja や /en から始まる文字列が入っている const lang = pathname.split("/")[1]; if (lang === "ja" || lang === "en") { return lang; } // 言語が指定されていなかったり、対応していない言語だった場合はnullを返すことで、remix-i18nextによる言語検知の結果を使用する return null; }, }, i18next: { ...i18n, backend: { loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"), }, }, plugins: [Backend], }); export default i18next; |
detectionの
findLocale()
関数を実装していることで、パスによる言語切り替えができるようになっています。ここでnullをreturnすると、react-i18nextによって自動的に判定されます。その場合、デフォルトでは、
searchParams
,
cookie
,
session
,
header
の優先度で判定が行われます。
headerを元にチェックする場合、リクエストヘッダの
Accept-Language
を元に判断されます。この値はページ遷移時にブラウザがリクエストヘッダに乗せてくれるため、ほとんどこの値を見るだけで正しく判断できると思います。
entry.server.tsxの実装
React Router v7でも引き続き
entry.server.tsx
は同じ役目を果たします。ここでは、先ほど作成した
i18next.server.ts
の
Remixi18Next
による言語検知を使用しつつ、実際に各画面でi18nできるようにreact-i18nextの
I18nextProvider
をコンポーネントのトップ階層になるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | import { resolve } from "node:path"; import { createInstance } from "i18next"; import Backend from "i18next-fs-backend"; import { isbot } from "isbot"; import { renderToReadableStream } from "react-dom/server"; import { I18nextProvider, initReactI18next } from "react-i18next"; import type { AppLoadContext, EntryContext } from "react-router"; import { ServerRouter } from "react-router"; import i18n from "~/i18n"; import i18next from "~/i18next.server"; export default async function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, reactRouterContext: EntryContext, // eslint-disable-next-line @typescript-eslint/no-unused-vars loadContext: AppLoadContext, ) { const instance = createInstance(); // i18next.server.tsにあるRemixI18Nextを使い、言語を取得する const lng = await i18next.getLocale(request); const ns = i18next.getRouteNamespaces(reactRouterContext); await instance .use(initReactI18next) .use(Backend) .init({ ...i18n, lng, ns, backend: { loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json") }, }); const body = await renderToReadableStream( <I18nextProvider i18n={instance}> <ServerRouter context={reactRouterContext} url={request.url} /> </I18nextProvider>, { signal: request.signal, onError(error: unknown) { console.error(error); responseStatusCode = 500; }, }, ); if (isbot(request.headers.get("user-agent") || "")) { await body.allReady; } responseHeaders.set("Content-Type", "text/html"); return new Response(body, { headers: responseHeaders, status: responseStatusCode, }); } |
entry.client.tsxの実装
こちらも、React Router v7でも引き続き同じ役目を果たします。こちらは、クライアントでのハイドレーションやページ内遷移、言語切り替えに対応するための実装になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | import i18next from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import Backend from "i18next-http-backend"; import { StrictMode, startTransition } from "react"; import { hydrateRoot } from "react-dom/client"; import { I18nextProvider, initReactI18next } from "react-i18next"; import { HydratedRouter } from "react-router/dom"; import { getInitialNamespaces } from "remix-i18next/client"; import i18n from "~/i18n"; async function hydrate() { await i18next .use(initReactI18next) .use(LanguageDetector) .use(Backend) .init({ ...i18n, ns: getInitialNamespaces(), backend: { loadPath: "/locales/{{lng}}/{{ns}}.json" }, detection: { order: ["htmlTag"], caches: [], }, }); startTransition(() => { hydrateRoot( document, <I18nextProvider i18n={i18next}> <StrictMode> <HydratedRouter /> </StrictMode> </I18nextProvider>, ); }); } if (window.requestIdleCallback) { window.requestIdleCallback(hydrate); } else { window.setTimeout(hydrate, 1); } |
一見、entry.server.tsxと似たコードですが、entry.server.tsxではremix-i18nextが生成したi18nextインスタンスに対して設定をしていたのに対し、こちらはi18nextを直接importし、それに対して設定をしています。
また、
.init()
内で設定されている
detection.order
に注目してください。今回は
htmlTag
が設定されていますが、これによりハイドレーション時に
<html lang>
を参照してくれるようになります。
i18n対応
ここからは、実際に各ページでi18n対応をしてみます。
root.tsxの実装
React Router v7でも
root.tsx
は健在です。このファイルでは、DOMのトップ階層を定義するための
Layout
や、アプリケーションとしてのトップ階層となる
App
を定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | import { Link, Links, Meta, type MetaFunction, Outlet, Scripts, ScrollRestoration, useLoaderData, useLocation, } from "react-router"; import { useTranslation } from "react-i18next"; import { I18nLink } from "~/I18nLink"; import type { Route } from "./+types/root"; export async function loader({ request }: Route.LoaderArgs) { const { origin } = new URL(request.url); return { origin }; } export const meta: MetaFunction = () => { return [ { title: "What's Today", description: "Welcome to Remix!", }, ]; }; export const handle = { i18n: "common", }; export function Layout({ children }: { children: React.ReactNode }) { const { origin } = useLoaderData<typeof loader>(); const { i18n } = useTranslation(); return ( // 検知された言語をhtmlタグに設定する (dirは文字の表示方向) <html lang={i18n.language} dir={i18n.dir()}> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> {/*SEO対策として、他言語のページがあることを明示する*/} <link rel="alternate" hrefLang="en" href={`${origin}/en`} /> <link rel="alternate" hrefLang="ja" href={`${origin}/ja`} /> <link rel="alternate" hrefLang="x-default" href={`${origin}/en`} /> <Meta /> <Links /> </head> <body> {children} <ScrollRestoration /> <Scripts /> </body> </html> ); } |
SSRやハイドレーション時には、
i18n
に
entry.server.tsx
で検知された言語が入っているので、それを
<html lang>
に設定します。
次にAppを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | export default function App() { const { i18n } = useTranslation(); const location = useLocation(); const replaceLangPath = (lang: string) => { // 先頭に/jaまたは/enがある場合はlangで置換し、それ以外は先頭にlangを追加する const path = location.pathname; return path.startsWith("/ja") || path.startsWith("/en") ? path.replace(/^(\/ja|\/en)/, `/${lang}`) : `/${lang}${path}`; }; return ( <Link to={replaceLangPath("ja")} onClick={() => i18n.changeLanguage("ja")} > 日本語 </Link> {" | "} <Link to={replaceLangPath("en")} onClick={() => i18n.changeLanguage("en")} > English </Link> <div> <I18nLink to="">Index</I18nLink> {" | "} <I18nLink to="/subpage">Subpage</I18nLink> </div> <Outlet /> ); } // 言語プレフィックスを維持したままリンクを生成するコンポーネント const I18nLink = ({ to, children, }: { to: string; children: ReactNode }) => { const location = useLocation(); const lang = location.pathname.slice(1).split("/")[0]; return ( <Link to={["ja", "en"].includes(lang) ? `${lang}${to}` : to}> {children} </Link> ); }; |
今回、Appには言語切り替えリンクを実装してみました。言語を切り替えると、その言語用のパスに遷移しつつ、
i18n.changeLanguage()
で実際に言語を切り替えます。
ちなみに、直接
/en
や
/ja
にアクセスする分には問題ありませんでしたが、ページ内遷移では言語は変更されず、そのため
i18n.changeLanguage()
する必要がありました。
その下に、indexページとsubpageを行き来するためのリンクも置いておきました。こちらは
I18nLink
という
Link
をラップしたコンポーネントを作り、パスの言語プレフィックスを維持したままリンクを生成するようにしてみました。
言語切り替えのためのルーティング
今回はパスを使った言語切り替えを実装したいと思います。
1 2 3 4 5 6 7 8 9 10 11 12 13 | import { type RouteConfig, index, prefix, route, } from "@react-router/dev/routes"; export default [ ...prefix(":lang?", [ index("routes/index.tsx"), route("subpage", "routes/subpage.tsx"), ]), ] satisfies RouteConfig; |
...prefix(":lang?")
と定義することで、パスの先頭で言語を切り替えができるようになっています。また
?
がついているので、この指定はあってもなくても良いことになっています。
今回は、indexページとsubpageを作ってみます。
実際にi18nしてみる
それでは、最後にindexページとsubpageを作り、実際に翻訳ファイル内のメッセージを表示してみます。
まずはindexページから。
1 2 3 4 5 6 | import { useTranslation } from "react-i18next"; export default function Index() { const { t } = useTranslation(); return <div>{t("greeting")}</div>; } |
subpageはこちら。ほぼ同じですが、表示するメッセージを変えてみます。
1 2 3 4 5 6 | import { useTranslation } from "react-i18next"; export default function Index() { const { t } = useTranslation(); return <div>{t("subpage")}</div>; } |
これで完成です。
さいごに
React Router v7でも無事、i18nすることができました。SSRとハイドレーションが絡むので考慮しなければならないことが多く苦労しました。