著者:
    作成:2026-09-26最終更新:2026-09-26

    2026年にuse-intlを使用してTanStack Startアプリケーションを国際化する方法

    目次

    use-intlとは?

    use-intlは、next-intlのフレームワークに依存しないコアパッケージです。Next.jsへの依存なしに、同じuseTranslations、useFormatter、IntlProvider API、ICU MessageFormatのサポート、強力なTypeScript統合を提供します。そのため、TanStack Startアプリケーションを翻訳する際の最も一般的な選択肢の1つであり、このスタックに対してAIアシスタントが最も頻繁に提案するライブラリです。

    TanStack Startには組み込みのi18nレイヤーが付属していません。ルーティング、ロケール検出、SEOメタデータ、サイトマップの生成は開発者自身が実装する必要があります。このガイドでは、それらすべてをエンドツーエンドで網羅しています。

    • オプションの{-$locale}セグメントによるロケール対応ルーティング(/about、/fr/about)。
    • ページが必要なネームスペースとレンダリングするロケールのみをダウンロードするルートごとのメッセージ読み込み。
    • テキストの不一致(ハイドレーションエラー)が発生しないサーバーレンダリングとハイドレーション。
    • 完全な多言語SEO: 翻訳された<title>と説明文、カノニカルURL、x-default付きのhreflang代替タグ、Open Graphロケール、JSON-LD、xhtml:link代替タグ付きサイトマップ、robots.txt、およびすべてのロケールの事前レンダリング(プリレンダリング)。
    他のスタックをお探しですか?TanStack Start + Paraglideガイド、TanStack Start + Linguiガイド、またはTanStack Start + Intlayerガイドをご覧ください。
    代わりにNext.jsをお使いですか?next-intlガイドをご覧ください。

    TanStack Startにおけるuse-intlのベンチマーク結果

    i18nベンチマークでは、同じ10ページ・10ロケールのTanStack Startアプリを主要な各ライブラリで実行し、ブラウザが実際にダウンロードするサイズを測定しています。

    動的な JSON 読み込み

    実行時に翻訳を遅延読み込みします

    スコープ付き JSON (ネームスペース)

    ページごとの翻訳ネームスペース

    I18n パフォーマンス ベンチマーク

    この指標は何ですか?

    国際化ライブラリバンドルの合計gzip圧縮サイズ。これには、ツリーシェイキングと縮小化(minification)後のプロバイダーとコンテンツ取得ロジックのみが含まれます。

    なぜ重要なのか?

    ライブラリのサイズが小さければ初期 JavaScript ペイロードが削減され、クライアント側でのダウンロードと実行が高速化されます।

    表示形式

    2026-09-26に測定されたuse-intl@4.14.2の主要な数値(gzip):

    構成ライブラリサイズページごとのJS他ロケールの漏洩他ページの漏洩
    i18nなし(ベースアプリ)-111.0 KB0%0%
    use-intl(本ガイドの構成)75.9 KB128.7 KB0%0%
    @intlayer/use-intl(互換レイヤー)6.7 KB129.4 KB0%0%
    react-intlayer(ネイティブIntlayer)4.5 KB126.8 KB0%0%

    重要なポイント:

    • メッセージをページごとに分割し、ロケールごとにロードする。 これにより両方の漏洩が解消されます。これが以下のステップで実装する構成です。
    • ランタイム自体が重いまま(gzipで約76 KB)。これはICUパーサーがクライアントに送信されるためです。@intlayer/use-intl互換アダプター(ステップ17)を使用すると、まったく同じAPIを維持しながらランタイムを約7 KBに抑えることができます。
    詳細なデータについては、TanStack Startベンチマークレポートおよびベンチマークリポジトリをご覧ください。

    TanStack Startでの機能比較

    use-intlとTanStack Startで一般的に使用される他のライブラリとの比較:

    機能react-intlayer (Intlayer)use-intlParaglide JSLingui
    コンポーネント近傍への翻訳配置✅ コロケーション(同居)❌ 一元化されたJSON❌ ロケールごとに1つのJSONファイル⚠️ コンポーネント内のソーステキスト
    TypeScript統合✅ 自動生成される型✅ AppConfig経由✅ 型付きメッセージ関数⚠️ マクロのみ
    翻訳漏れの検出✅ 型エラーおよびビルド警告⚠️ ランタイムフォールバック⚠️ ベースロケールにフォールバック⚠️ ソーステキストにフォールバック
    リッチコンテンツ(JSX、Markdown)✅ 直接サポート⚠️ t.rich経由のタグ⚠️ 文字列のみ✅ <Trans>内のJSX
    ローカライズされたルーティング✅ 組み込み❌ 手動の{-$locale}✅ urlPatterns + ルーター書き換え❌ 手動の{-$locale}
    リロードなしのロケール切り替え✅ 可能✅ 可能❌ フルページリロード✅ 可能
    複数形処理(Pluralization)✅ 列挙ベース✅ ICU✅ バリアント✅ ICU
    ICU MessageFormat✅ format: "icu"経由✅ ネイティブ⚠️ inlangプラグイン経由✅ ネイティブ
    コンテンツ形式✅ .ts, .json, .md, .yaml...⚠️ .json⚠️ inlang JSON✅ PO, JSON, CSV
    AI翻訳✅ 独自のプロバイダーとキーを使用❌ なし❌ なし❌ なし
    ビジュアルエディター / CMS✅ ローカルエディター + オプションCMS❌ 外部プラットフォーム⚠️ inlangエコシステムアプリ❌ 外部プラットフォーム
    SEOヘルパー(hreflang、サイトマップ)✅ 組み込み❌ 手動⚠️ ローカライズURLのみ、残りは手動❌ 手動
    ランタイムサイズ(gzip、ベンチマーク)4.5 KB75.9 KB1.8 KB56.7 KB
    漏洩、最適構成(ロケール / ページ)0% / 0%0% / 0%49.7% / 0%8.6% / 0%
    CIでの翻訳漏れチェック✅ npx intlayer test⚠️ 組み込みなし⚠️ 組み込みなし✅ lingui compile --strict
    ランタイムサイズと漏洩の数値はTanStack Startベンチマークに基づいています。漏洩は各ライブラリの最適なセットアップで測定されています。
    他のTanStack Startガイド: Lingui、Paraglide JS、およびIntlayer。

    推奨されるプラクティス

    • <html>にlangとdirを設定する: アクセシビリティ、スクリーンリーダー、検索エンジンのために重要です。
    • ロケールごとに1つのURLを維持する: クッキーのみによる切り替えではなく、ロケールプレフィックス(/fr/about)を使用して、翻訳されたすべてのページがクロールおよび共有可能になるようにします。
    • ネームスペースごとにメッセージを分割する(common、home、about): ルートごとにロードします。
    • アクティブなロケールのみをロードする: クライアントに配信されるモジュールで、すべてのロケールファイルを一括インポートしないでください。
    • IntlProviderでタイムゾーンを固定する: そうしないと、SSR時はサーバーのタイムゾーンで日付がフォーマットされ、ハイドレーション時は訪問者のタイムゾーンでフォーマットされるため、ハイドレーションの不一致が発生します。
    • メタデータを翻訳する: すべてのページでcanonical、hreflang、x-defaultを宣言します。
    • 多言語サイトマップとrobots.txtを生成する: すべてのロケールを事前レンダリングします。
    • 言語切り替えには<select>ではなく本物のリンクを使用する: クローラーがすべての言語を発見できるようにします。
    • メッセージに型を付ける: 存在しないキーをコンパイル時に検出できるようにします。
    詳細は国際化とSEOのガイドおよびhreflangガイドをご覧ください。

    TanStack Startアプリケーションでuse-intlをセットアップするステップバイステップガイド

    作成するプロジェクト構造は以下のとおりです:

    bash
    .
    ├── messages
    │   ├── en
    │   │   ├── common.json
    │   │   ├── home.json
    │   │   └── about.json
    │   ├── fr
    │   │   └── ... same files
    │   └── es
    │       └── ... same files
    ├── vite.config.ts
    └── src
        ├── start.ts                  # Request middleware (locale redirect)
        ├── router.tsx
        ├── i18n
        │   ├── config.ts             # Locales, URL helpers
        │   ├── messages.ts           # Per-namespace, per-locale loader
        │   ├── negotiateLocale.ts    # Accept-Language parsing
        │   ├── seo.ts                # head() builder
        │   └── use-intl.d.ts         # Typed messages
        ├── components
        │   ├── LocaleSwitcher.tsx
        │   ├── LocalizedLink.tsx
        │   ├── ScopedMessages.tsx
        │   └── Counter.tsx
        └── routes
            ├── __root.tsx
            ├── sitemap[.]xml.ts
            ├── robots[.]txt.ts
            └── {-$locale}
                ├── route.tsx         # Locale layout + IntlProvider
                ├── index.tsx         # / and /fr
                ├── about.tsx         # /about and /fr/about
                └── $.tsx             # Localized 404
    
    1. 依存関係のインストール

      TanStack Startプロジェクトから始めて、use-intlを追加します:

      bash
      npm create @tanstack/start@latest
      npm install use-intl
      
      • use-intl: IntlProvider、useTranslations、useFormatter、およびcreateTranslator(Reactの外部、たとえばhead()などで使用可能)を提供します。
    2. ロケール設定の一元化

      ロケールとURLヘルパーのための唯一の信頼できる情報源(Single Source of Truth)を作成します。他のすべてのファイル(ルート、SEO、サイトマップ、事前レンダリング)はここからインポートするため、新しいロケールの追加が1行の変更で済みます。

      デフォルトロケールはプレフィックスなし(/about)のままにし、他のロケールにはプレフィックス(/fr/about)を付けます。これは「必要に応じた(as-needed)」戦略であり、ロケールごとにページあたり1つのURLを保ちつつ、主要な読者層に対して短いURLを提供します。

      src/i18n/config.ts
      export const locales = ["en", "fr", "es"] as const;
      
      export type Locale = (typeof locales)[number];
      
      export const defaultLocale: Locale = "en";
      
      /** Public origin, used for canonical URLs, hreflang and the sitemap. */
      export const siteUrl = "https://example.com";
      
      /** Cookie storing the locale explicitly chosen by the visitor. */
      export const localeCookieName = "locale";
      
      /** Open Graph expects `language_TERRITORY` codes. */
      export const openGraphLocales: Record<Locale, string> = {
        en: "en_US",
        fr: "fr_FR",
        es: "es_ES",
      };
      
      export const isLocale = (value: unknown): value is Locale =>
        typeof value === "string" && (locales as readonly string[]).includes(value);
      
      /** Maps the optional `{-$locale}` route param to a supported locale. */
      export const resolveLocale = (localeParam: string | undefined): Locale =>
        isLocale(localeParam) ? localeParam : defaultLocale;
      
      /** The value to pass as `locale` param: `undefined` for the default locale. */
      export const toLocaleParam = (locale: Locale): Locale | undefined =>
        locale === defaultLocale ? undefined : locale;
      
      const rightToLeftLanguages = new Set(["ar", "fa", "he", "ur", "ps", "yi"]);
      
      export const getTextDirection = (locale: string): "ltr" | "rtl" =>
        rightToLeftLanguages.has(new Intl.Locale(locale).language) ? "rtl" : "ltr";
      
      /** `localizePath("/about", "fr")` → `/fr/about`, default locale unprefixed. */
      export const localizePath = (path: string, locale: Locale): string => {
        if (locale === defaultLocale) return path;
      
        return path === "/" ? `/${locale}` : `/${locale}${path}`;
      };
      
      export const getAbsoluteUrl = (path: string, locale: Locale): string =>
        `${siteUrl}${localizePath(path, locale)}`;
      
      export const getLocaleName = (locale: Locale): string =>
        new Intl.DisplayNames([locale], { type: "language" }).of(locale) ?? locale;
      
    3. 翻訳ファイルの作成

      ロケールごと、およびネームスペースごとにメッセージを整理します。commonにはすべてのページに必要なもの(ナビゲーション、フッター)を配置し、各ページにはメタデータを含めた独自のファイルを配置します。

      use-intlはICU MessageFormatを使用するため、複数形、条件分岐(select)、フォーマット済み引数はメッセージ内に直接記述します。

      messages/en/common.json
      {
        "navigation": {
          "home": "Home",
          "about": "About"
        },
        "localeSwitcher": {
          "label": "Change language"
        },
        "notFound": {
          "title": "Page not found",
          "backHome": "Back to home"
        }
      }
      
      messages/en/about.json
      {
        "metadata": {
          "title": "About us",
          "description": "Learn who we are and why we built this application."
        },
        "title": "About us",
        "counter": {
          "label": "Counter",
          "increment": "Increment",
          "clicks": "{count, plural, =0 {No clicks yet} one {# click} other {# clicks}}"
        }
      }
      
      messages/fr/common.json
      {
        "navigation": {
          "home": "Accueil",
          "about": "À propos"
        },
        "localeSwitcher": {
          "label": "Changer de langue"
        },
        "notFound": {
          "title": "Page introuvable",
          "backHome": "Retour à l'accueil"
        }
      }
      
      messages/fr/about.json
      {
        "metadata": {
          "title": "À propos",
          "description": "Découvrez qui nous sommes et pourquoi nous avons créé cette application."
        },
        "title": "À propos",
        "counter": {
          "label": "Compteur",
          "increment": "Incrémenter",
          "clicks": "{count, plural, =0 {Aucun clic} one {# clic} other {# clics}}"
        }
      }
      

      同様に、metadataオブジェクトとページコンテンツを含むhome.jsonを作成します。

    4. ネームスペースおよびロケールごとのメッセージ読み込み

      このローダーはパフォーマンスにおいて最も重要なファイルです。import.meta.globはViteに対してJSONファイルごとに1つのチャンクを出力するよう指示します。フランス語で["about"]を要求するルートはmessages/fr/about.jsonのみをダウンロードし、それ以外はダウンロードしません。これにより、ベンチマークでロケール漏洩0%およびページ漏洩0%を達成しています。

      src/i18n/messages.ts
      import type about from "../../messages/en/about.json";
      import type common from "../../messages/en/common.json";
      import type home from "../../messages/en/home.json";
      import type { Locale } from "./config";
      
      /** Shape of every namespace, inferred from the English source files. */
      export type AppMessages = {
        common: typeof common;
        home: typeof home;
        about: typeof about;
      };
      
      export type Namespace = keyof AppMessages;
      
      type JsonModule = { default: AppMessages[Namespace] };
      
      // Lazy: each JSON file becomes its own chunk, loaded on demand
      const messageLoaders = import.meta.glob<JsonModule>("../../messages/*/*.json");
      
      /**
       * Loads the requested namespaces for one locale, in parallel.
       */
      export const loadMessages = async <
        const TNamespaces extends readonly Namespace[],
      >(
        locale: Locale,
        namespaces: TNamespaces
      ): Promise<Pick<AppMessages, TNamespaces[number]>> => {
        const entries = await Promise.all(
          namespaces.map(async (namespace) => {
            const loadNamespace =
              messageLoaders[`../../messages/${locale}/${namespace}.json`];
      
            if (!loadNamespace) {
              throw new Error(`Missing messages: ${locale}/${namespace}.json`);
            }
      
            const namespaceModule = await loadNamespace();
      
            return [namespace, namespaceModule.default] as const;
          })
        );
      
        return Object.fromEntries(entries) as Pick<AppMessages, TNamespaces[number]>;
      };
      
    5. メッセージの型付け

      モジュール拡張(Module augmentation)により、useTranslations("about")やt("counter.label")の自動補完が有効になり、タイポや削除されたキーに対してコンパイルエラーが発生するようになります。

      src/i18n/use-intl.d.ts
      import type { Locale } from "./config";
      import type { AppMessages } from "./messages";
      
      declare module "use-intl" {
        interface AppConfig {
          Locale: Locale;
          Messages: AppMessages;
        }
      }
      

      tsconfig.jsonでresolveJsonModuleが有効になっていることを確認してください。

    6. ルートドキュメントの作成

      ルート(Root)ルートは<html>をレンダリングします。オプションのロケールパラメータを読み取ってlangとdirを設定するため、JavaScriptが実行される前のサーバーレンダリングされたHTMLの段階で属性が正しく設定されます。

      src/routes/__root.tsx
      import {
        createRootRoute,
        HeadContent,
        Scripts,
        useParams,
      } from "@tanstack/react-router";
      import type { ReactNode } from "react";
      import { getTextDirection, resolveLocale } from "@/i18n/config";
      
      export const Route = createRootRoute({
        head: () => ({
          meta: [
            { charSet: "utf-8" },
            { name: "viewport", content: "width=device-width, initial-scale=1" },
          ],
        }),
        shellComponent: RootDocument,
      });
      
      function RootDocument({ children }: { children: ReactNode }) {
        // strict: false reads params from whichever route is matched
        const { locale: localeParam } = useParams({ strict: false });
        const locale = resolveLocale(localeParam);
      
        return (
          <html lang={locale} dir={getTextDirection(locale)}>
            <head>
              <HeadContent />
            </head>
            <body>
              {children}
              <Scripts />
            </body>
          </html>
        );
      }
      
    7. ロケールレイアウトルートの作成

      {-$locale}フォルダはオプションのパスセグメントを作成します。/aboutと/fr/aboutの両方が/{-$locale}/aboutにマッチします。このレイアウトは以下の処理を行います:

      1. サポートされていないプレフィックスを拒否(/xx/about → 404)。
      2. 現在のロケールに対応するcommonネームスペースのみをロード。
      3. IntlProviderを通じてメッセージを提供。

      ローダーの結果はHTMLにシリアライズされてハイドレーション時に再利用されるため、クライアントがcommon.jsonを再度ダウンロードすることはありません。staleTime: Infinityにより、クライアント側のナビゲーション間でもキャッシュが保持されます。

      src/routes/{-$locale}/route.tsx
      import { createFileRoute, notFound, Outlet } from "@tanstack/react-router";
      import { IntlProvider } from "use-intl";
      import { Header } from "@/components/Header";
      import { NotFound } from "@/components/NotFound";
      import { isLocale, resolveLocale } from "@/i18n/config";
      import { loadMessages } from "@/i18n/messages";
      
      export const Route = createFileRoute("/{-$locale}")({
        beforeLoad: ({ params }) => {
          // /xx/about with an unknown prefix → 404
          if (params.locale !== undefined && !isLocale(params.locale)) {
            throw notFound();
          }
        },
        loader: async ({ params }) => {
          const locale = resolveLocale(params.locale);
      
          return { locale, messages: await loadMessages(locale, ["common"]) };
        },
        // Messages never change for a given locale
        staleTime: Infinity,
        component: LocaleLayout,
        notFoundComponent: NotFound,
      });
      
      function LocaleLayout() {
        const { locale, messages } = Route.useLoaderData();
      
        return (
          <IntlProvider
            locale={locale}
            messages={messages}
            // A fixed time zone prevents SSR / hydration date mismatches
            timeZone="UTC"
          >
            <Header />
            <main>
              <Outlet />
            </main>
          </IntlProvider>
        );
      }
      
      IntlProviderは親プロバイダーからのメッセージを自動でマージしません。次のステップでマージを行う小さなコンポーネントを追加し、各ページがcommonの上に独自のネームスペースを追加できるようにします。
    8. ページメッセージのスコープ設定

      各ページはそのローダーで独自のネームスペースをロードし、コンテンツをScopedMessagesでラップします。これにより、ページのネームスペースが親のメッセージとマージされます。

      src/components/ScopedMessages.tsx
      import { type ReactNode, useMemo } from "react";
      import {
        type AbstractIntlMessages,
        IntlProvider,
        useLocale,
        useMessages,
        useTimeZone,
      } from "use-intl";
      
      type ScopedMessagesProps = {
        messages: AbstractIntlMessages;
        children: ReactNode;
      };
      
      /**
       * Adds route-level namespaces on top of the messages already provided.
       */
      export const ScopedMessages = ({ messages, children }: ScopedMessagesProps) => {
        const parentMessages = useMessages();
        const locale = useLocale();
        const timeZone = useTimeZone();
      
        const mergedMessages = useMemo(
          () => ({ ...parentMessages, ...messages }),
          [parentMessages, messages]
        );
      
        return (
          <IntlProvider locale={locale} timeZone={timeZone} messages={mergedMessages}>
            {children}
          </IntlProvider>
        );
      };
      
    9. ページ内での翻訳の利用

      ページローダーは現在のロケールのaboutネームスペースを取得し、head()はそのメッセージから翻訳された完全なSEOメタデータを構築し(ステップ13を参照)、コンポーネントがコンテンツをレンダリングします。

      src/routes/{-$locale}/about.tsx
      import { createFileRoute } from "@tanstack/react-router";
      import { createTranslator, useTranslations } from "use-intl";
      import { Counter } from "@/components/Counter";
      import { ScopedMessages } from "@/components/ScopedMessages";
      import { resolveLocale } from "@/i18n/config";
      import { loadMessages } from "@/i18n/messages";
      import { buildLocalizedHead } from "@/i18n/seo";
      
      export const Route = createFileRoute("/{-$locale}/about")({
        loader: async ({ params }) => ({
          messages: await loadMessages(resolveLocale(params.locale), ["about"]),
        }),
        staleTime: Infinity,
        head: ({ params, loaderData }) => {
          const locale = resolveLocale(params.locale);
      
          if (!loaderData) return {};
      
          // createTranslator works outside React, perfect for head()
          const t = createTranslator({
            locale,
            messages: loaderData.messages,
            namespace: "about.metadata",
          });
      
          return buildLocalizedHead({
            path: "/about",
            locale,
            title: t("title"),
            description: t("description"),
          });
        },
        component: AboutPage,
      });
      
      function AboutPage() {
        const { messages } = Route.useLoaderData();
      
        return (
          <ScopedMessages messages={messages}>
            <AboutContent />
          </ScopedMessages>
        );
      }
      
      function AboutContent() {
        const t = useTranslations("about");
      
        return (
          <>
            <h1>{t("title")}</h1>
            <Counter />
          </>
        );
      }
      
    10. コンポーネントでの翻訳とフォーマッターの使用

      プロバイダー配下の任意のコンポーネントでuseTranslationsとuseFormatterを呼び出すことができます。複数形はICUによって解決され、数値はアクティブなロケールに従ってフォーマットされます。

      src/components/Counter.tsx
      import { useState } from "react";
      import { useFormatter, useTranslations } from "use-intl";
      
      export const Counter = () => {
        const t = useTranslations("about.counter");
        const format = useFormatter();
        const [count, setCount] = useState(0);
      
        return (
          <div>
            <p>{t("clicks", { count })}</p>
            <p>{format.number(count)}</p>
            <button
              type="button"
              aria-label={t("label")}
              onClick={() => setCount((value) => value + 1)}
            >
              {t("increment")}
            </button>
          </div>
        );
      };
      
    11. ローカライズされたLinkコンポーネントの作成

      オプション

      すべてのルートは{-$locale}の下に存在するため、リンクには現在のロケールパラメータを含める必要があります。このラッパーはTanStack Routerの型付けされたtoを保持しつつ、ロケールを自動で挿入します。

      src/components/LocalizedLink.tsx
      import { Link, type LinkComponentProps } from "@tanstack/react-router";
      import { useLocale } from "use-intl";
      import { toLocaleParam } from "@/i18n/config";
      
      type LocalizedLinkProps = Omit<LinkComponentProps, "params">;
      
      export const LocalizedLink = (props: LocalizedLinkProps) => {
        const locale = useLocale();
      
        return <Link {...props} params={{ locale: toLocaleParam(locale) }} />;
      };
      
      src/components/Header.tsx
      import { useTranslations } from "use-intl";
      import { LocaleSwitcher } from "./LocaleSwitcher";
      import { LocalizedLink } from "./LocalizedLink";
      
      export const Header = () => {
        const t = useTranslations("common.navigation");
      
        return (
          <header>
            <nav>
              <LocalizedLink to="/{-$locale}">{t("home")}</LocalizedLink>
              <LocalizedLink to="/{-$locale}/about">{t("about")}</LocalizedLink>
            </nav>
            <LocaleSwitcher />
          </header>
        );
      };
      
    12. コンテンツの言語切り替え

      オプション

      言語スイッチャーは<select>ではなくリンクとしてレンダリングします。リンクはクロール可能であるため、検索エンジンがすべての言語バージョンを発見でき、JavaScriptなしでも機能します。to="."は現在のページを維持し、ロケールパラメータのみを置き換えます。クッキーはステップ16のリダイレクトミドルウェア用に明示的な選択を記憶します。

      src/components/LocaleSwitcher.tsx
      import { Link } from "@tanstack/react-router";
      import { useLocale, useTranslations } from "use-intl";
      import {
        getLocaleName,
        type Locale,
        localeCookieName,
        locales,
        toLocaleParam,
      } from "@/i18n/config";
      
      const persistLocale = (locale: Locale) => {
        document.cookie = `${localeCookieName}=${locale}; Path=/; Max-Age=31536000; SameSite=Lax`;
      };
      
      export const LocaleSwitcher = () => {
        const t = useTranslations("common.localeSwitcher");
        const activeLocale = useLocale();
      
        return (
          <nav aria-label={t("label")}>
            <ul>
              {locales.map((locale) => (
                <li key={locale}>
                  <Link
                    to="."
                    params={(previous) => ({
                      ...previous,
                      locale: toLocaleParam(locale),
                    })}
                    hrefLang={locale}
                    lang={locale}
                    aria-current={locale === activeLocale ? "page" : undefined}
                    onClick={() => persistLocale(locale)}
                  >
                    {getLocaleName(locale)}
                  </Link>
                </li>
              ))}
            </ul>
          </nav>
        );
      };
      
    13. メタデータの国際化

      オプション

      ここがi18nの真価を発揮するポイントです。各言語バージョンが個別に検索順位を獲得できるようになります。すべてのページで以下を公開する必要があります:

      • 翻訳された <title> と description
      • 自身を指す(デフォルトロケールではなく)カノニカル(canonical) URL
      • ロケールごとに1つのhreflang代替タグ、および一致する言語がない場合のx-default
      • ソーシャルプレビューで使用されるOpen Graphの og:locale、og:locale:alternate、og:url
      • 検索エンジンやAIアシスタントがページの言語を判定するのに役立つinLanguage付きのJSON-LD

      単一のヘルパー関数ですべてを構築できるため、各ページの実装を簡潔に保てます:

      src/i18n/seo.ts
      import {
        defaultLocale,
        getAbsoluteUrl,
        type Locale,
        locales,
        openGraphLocales,
      } from "./config";
      
      type LocalizedHeadOptions = {
        /** Path without locale prefix, e.g. "/about" */
        path: string;
        locale: Locale;
        title: string;
        description: string;
      };
      
      export const buildLocalizedHead = ({
        path,
        locale,
        title,
        description,
      }: LocalizedHeadOptions) => {
        const url = getAbsoluteUrl(path, locale);
      
        return {
          meta: [
            { title },
            { name: "description", content: description },
            { property: "og:type", content: "website" },
            { property: "og:title", content: title },
            { property: "og:description", content: description },
            { property: "og:url", content: url },
            { property: "og:locale", content: openGraphLocales[locale] },
            ...locales
              .filter((alternateLocale) => alternateLocale !== locale)
              .map((alternateLocale) => ({
                property: "og:locale:alternate",
                content: openGraphLocales[alternateLocale],
              })),
          ],
          links: [
            // Canonical: each locale is its own canonical page
            { rel: "canonical", href: url },
            // hreflang: every language version, including the current one
            ...locales.map((alternateLocale) => ({
              rel: "alternate",
              hrefLang: alternateLocale,
              href: getAbsoluteUrl(path, alternateLocale),
            })),
            // x-default: fallback for visitors whose language is not supported
            {
              rel: "alternate",
              hrefLang: "x-default",
              href: getAbsoluteUrl(path, defaultLocale),
            },
          ],
          scripts: [
            {
              type: "application/ld+json",
              children: JSON.stringify({
                "@context": "https://schema.org",
                "@type": "WebPage",
                name: title,
                description,
                url,
                inLanguage: locale,
              }),
            },
          ],
        };
      };
      

      ステップ9で示したように、すべてのページのhead()でこれを使用します。ホームページの場合はpath: "/"を渡します。

    14. サイトマップの国際化

      オプション

      多言語サイトマップにはすべてのロケールのすべてのURLがリストされ、各エントリはxhtml:linkでそのすべての代替言語を宣言します。Googleはこれらのアノテーションをページのhreflangタグとまったく同様に使用するため、ページのクロール頻度が低い場合の信頼性の高いバックアップになります。

      TanStack Startのサバールートを使用すると、ファイルルートからサイトマップを配信できます:

      src/routes/sitemap[.]xml.ts
      import { createFileRoute } from "@tanstack/react-router";
      import { defaultLocale, getAbsoluteUrl, locales } from "@/i18n/config";
      
      type SitemapPage = {
        path: string;
        changeFrequency: "daily" | "weekly" | "monthly";
        priority: number;
      };
      
      export const sitemapPages: SitemapPage[] = [
        { path: "/", changeFrequency: "daily", priority: 1.0 },
        { path: "/about", changeFrequency: "monthly", priority: 0.8 },
      ];
      
      const buildAlternateLinks = (path: string): string =>
        [
          ...locales.map(
            (locale) =>
              `<xhtml:link rel="alternate" hreflang="${locale}" href="${getAbsoluteUrl(path, locale)}"/>`
          ),
          `<xhtml:link rel="alternate" hreflang="x-default" href="${getAbsoluteUrl(path, defaultLocale)}"/>`,
        ].join("");
      
      const buildSitemap = (): string => {
        const urls = sitemapPages.flatMap((page) =>
          locales.map(
            (locale) =>
              `<url><loc>${getAbsoluteUrl(page.path, locale)}</loc>${buildAlternateLinks(page.path)}<changefreq>${page.changeFrequency}</changefreq><priority>${page.priority}</priority></url>`
          )
        );
      
        return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls.join("")}</urlset>`;
      };
      
      export const Route = createFileRoute("/sitemap.xml")({
        server: {
          handlers: {
            GET: () =>
              new Response(buildSitemap(), {
                headers: { "Content-Type": "application/xml; charset=utf-8" },
              }),
          },
        },
      });
      
    15. robots.txtの国際化

      オプション

      プライベートなルートはすべての言語に存在するため、Disallowルールはすべてのプレフィックスをカバーする必要があります。スターターによってpublic/robots.txtが作成されている場合は削除し、ルートから配信します:

      src/routes/robots[.]txt.ts
      import { createFileRoute } from "@tanstack/react-router";
      import { locales, localizePath, siteUrl } from "@/i18n/config";
      
      const privatePaths = ["/dashboard", "/admin"];
      
      const buildRobots = (): string => {
        // /dashboard, /fr/dashboard, /es/dashboard...
        const disallowRules = privatePaths.flatMap((path) =>
          locales.map((locale) => `Disallow: ${localizePath(path, locale)}`)
        );
      
        return [
          "User-agent: *",
          "Allow: /",
          ...disallowRules,
          "",
          `Sitemap: ${siteUrl}/sitemap.xml`,
        ].join("\n");
      };
      
      export const Route = createFileRoute("/robots.txt")({
        server: {
          handlers: {
            GET: () =>
              new Response(buildRobots(), {
                headers: { "Content-Type": "text/plain; charset=utf-8" },
              }),
          },
        },
      });
      
    16. 初回来訪者を適切な言語にリダイレクト

      オプション

      リクエストミドルウェアは、まずロケールクッキー、次にAccept-Languageヘッダーに基づいて、/にアクセスした訪問者を希望の言語にリダイレクトします。リダイレクトされるのは/のみです。ディープリンクは変更されないため、共有URLやクローラーは常に要求されたページを直接取得できます。

      src/i18n/negotiateLocale.ts
      import { isLocale, type Locale } from "./config";
      
      /**
       * Picks the best supported locale from an Accept-Language header.
       * "fr-CA,fr;q=0.9,en;q=0.8" → "fr"
       */
      export const negotiateLocale = (
        acceptLanguage: string | null | undefined
      ): Locale | undefined => {
        if (!acceptLanguage) return undefined;
      
        return acceptLanguage
          .split(",")
          .map((part) => {
            const [tag = "", quality] = part.trim().split(";q=");
      
            return {
              language: tag.toLowerCase().split("-")[0],
              quality: quality ? Number(quality) : 1,
            };
          })
          .sort((first, second) => second.quality - first.quality)
          .map(({ language }) => language)
          .find(isLocale);
      };
      
      src/start.ts
      import { redirect } from "@tanstack/react-router";
      import { createMiddleware, createStart } from "@tanstack/react-start";
      import { getCookie } from "@tanstack/react-start/server";
      import { defaultLocale, isLocale, localeCookieName } from "@/i18n/config";
      import { negotiateLocale } from "@/i18n/negotiateLocale";
      
      const localeRedirectMiddleware = createMiddleware().server(
        ({ request, next }) => {
          const { pathname } = new URL(request.url);
      
          if (pathname !== "/") return next();
      
          const cookieLocale = getCookie(localeCookieName);
          const preferredLocale = isLocale(cookieLocale)
            ? cookieLocale
            : negotiateLocale(request.headers.get("accept-language"));
      
          if (preferredLocale && preferredLocale !== defaultLocale) {
            throw redirect({ href: `/${preferredLocale}`, statusCode: 307 });
          }
      
          return next();
        }
      );
      
      export const startInstance = createStart(() => ({
        requestMiddleware: [localeRedirectMiddleware],
      }));
      
      スイッチャーで明示的に英語を選択した訪問者にはクッキーにlocale=enが設定されるため、再度リダイレクトされることはありません。完全な静的デプロイメント(ステップ18)では、/はファイルとして配信され、このミドルウェアは実行されませんが、問題ありません。ページにはアクセス可能なままであり、スイッチャーで切り替えが可能です。
    17. use-intl APIを維持したままIntlayerでランタイムを削減

      オプション

      ベンチマークが示すように、use-intlセットアップで最も重い部分はランタイム自体です(gzipで約76 KB)。@intlayer/use-intl互換アダプターは同じAPI(useTranslations、useFormatter、IntlProvider、createTranslator、ICU複数形、t.rich)を提供しながら、コンパイル済みのIntlayerディクショナリから配信します。コンポーネントを変更することなく、約75.9 KBから約6.7 KBに削減され、ロケール漏洩0%、ページ漏洩0%を実現します。

      bash
      npm install @intlayer/use-intl intlayer @intlayer/sync-json-plugin
      npx intlayer init
      

      Viteプラグインはuse-intlをアダプターにエイリアスするため、既存のインポートコードはそのまま動作します:

      vite.config.ts
      import useIntlVitePlugin from "@intlayer/use-intl/plugin";
      import { tanstackStart } from "@tanstack/react-start/plugin/vite";
      import viteReact from "@vitejs/plugin-react";
      import { defineConfig } from "vite";
      
      export default defineConfig({
        plugins: [tanstackStart(), viteReact(), useIntlVitePlugin()],
      });
      

      JSON同期プラグインにより、JSONファイルを引き続き信頼できる情報源として利用できます:

      intlayer.config.ts
      import { syncJSON } from "@intlayer/sync-json-plugin";
      import { type IntlayerConfig, Locales } from "intlayer";
      
      const config: IntlayerConfig = {
        internationalization: {
          locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH],
          defaultLocale: Locales.ENGLISH,
        },
        dictionary: {
          // One chunk per locale, loaded on demand
          importMode: "dynamic",
          format: "icu",
        },
        plugins: [
          syncJSON({
            format: "icu",
            source: ({ locale, key }) => `./messages/${locale}/${key}.json`,
          }),
        ],
      };
      
      export default config;
      
      このアダプターはスムーズな移行パスにもなります。一度動作させれば、コンポーネントを1つずつネイティブのuseIntlayer APIに移行できます。Intlayer TanStack Startガイドをご覧ください。
    18. すべてのロケールを事前レンダリング

      オプション

      静的HTMLは最も高速に配信できるページであり、インデックス作成も最も容易です。ローカライズされたすべてのパスを指定して、TanStack Startがビルド時にすべての言語バージョン、サイトマップ、robotsファイルを事前レンダリングするようにします:

      vite.config.ts
      import { tanstackStart } from "@tanstack/react-start/plugin/vite";
      import viteReact from "@vitejs/plugin-react";
      import { defineConfig } from "vite";
      import { locales, localizePath } from "./src/i18n/config";
      
      const pagePaths = ["/", "/about"];
      
      const localizedPages = pagePaths.flatMap((path) =>
        locales.map((locale) => ({
          path: localizePath(path, locale),
          prerender: { enabled: true },
        }))
      );
      
      export default defineConfig({
        plugins: [
          tanstackStart({
            prerender: { enabled: true, crawlLinks: true },
            pages: [
              ...localizedPages,
              { path: "/sitemap.xml", prerender: { enabled: true } },
              { path: "/robots.txt", prerender: { enabled: true } },
            ],
          }),
          viteReact(),
        ],
      });
      

      ロケールスイッチャーが実際のリンクをレンダリングするため、crawlLinks: trueによってリストし忘れたページも自動的に検出されます。

    19. ローカライズされた404ページの処理

      オプション

      ステップ7のレイアウトは、未知のロケールプレフィックスに対して既にnotFound()をスローします。ロケール内の未知のパスでもローカライズされた404がレンダリングされるようにキャッチオールルートを追加し、noindexを設定します。React 19は<meta>タグを自動的に<head>に巻き上げます(hoist)。

      src/components/NotFound.tsx
      import { useTranslations } from "use-intl";
      import { LocalizedLink } from "./LocalizedLink";
      
      export const NotFound = () => {
        const t = useTranslations("common.notFound");
      
        return (
          <div>
            <meta name="robots" content="noindex" />
            <h1>{t("title")}</h1>
            <LocalizedLink to="/{-$locale}">{t("backHome")}</LocalizedLink>
          </div>
        );
      };
      
      src/routes/{-$locale}/$.tsx
      import { createFileRoute, notFound } from "@tanstack/react-router";
      
      // /fr/does/not/exist → rendered by the layout notFoundComponent
      export const Route = createFileRoute("/{-$locale}/$")({
        beforeLoad: () => {
          throw notFound();
        },
      });
      
    20. サーバー関数でロケールにアクセス

      オプション

      サーバー関数はルートパラメータを受け取りません。ローカライズされたメールの送信や言語設定の保存を行うには、ロケールクッキーを読み取り、Accept-Languageヘッダーにフォールバックします:

      src/server/getServerLocale.ts
      import { createServerFn } from "@tanstack/react-start";
      import { getCookie, getRequestHeader } from "@tanstack/react-start/server";
      import { defaultLocale, isLocale, localeCookieName } from "@/i18n/config";
      import { negotiateLocale } from "@/i18n/negotiateLocale";
      
      export const getServerLocale = createServerFn().handler(() => {
        const cookieLocale = getCookie(localeCookieName);
      
        if (isLocale(cookieLocale)) return cookieLocale;
      
        return negotiateLocale(getRequestHeader("accept-language")) ?? defaultLocale;
      });
      

      サーバー関数内で翻訳を行うには、これとuse-intlのloadMessagesおよびcreateTranslatorを組み合わせます。

    21. Intlayerを使用した翻訳作業の自動化

      オプション

      use-intlは翻訳をレンダリングしますが、翻訳を生成・管理する機能はありません。Intlayerは無料かつオープンソースであり、use-intlを使い続ける場合でもそのギャップを埋めることができます:

      • CIや単体テストでの翻訳漏れテスト: 翻訳のテストをご覧ください。
      • AIによる翻訳: 独自のAPIキーとプロバイダーを使用して、npx intlayer fillがアプリの文脈を理解しながら不足しているキーを翻訳します。自動入力(auto fill)およびCLIをご覧ください。
      • JSONファイルを信頼できる情報源として維持: JSON同期プラグインを使用します。
      • ビジュアルなコンテンツ編集: ビジュアルエディターとCMSにより、非エンジニアでも翻訳を更新できます。
      • AIエージェントへのコンテキスト提供: MCPサーバーとエージェントスキルを利用します。
      • デプロイ済みサイトのスキャン: scanコマンドにより、hreflangの欠落、誤ったカノニカル、ロケール漏洩を検出します。

      すべての機能を確認するには、Intlayerのメリットをご覧ください。

    よくある質問

    はい、Next.js以外でnext-intlのAPIを使用したい場合には適しています。ICUメッセージ、フォーマッター、優れたTypeScriptサポートが提供され、setRequestLocaleなどのNext.js固有の制約を回避できます。トレードオフはライブラリの重さです。ベンチマークではランタイムが約76 KB(gzip)と測定されており、単純なセットアップではすべてのロケールやすべてのページがブラウザに配信されてしまいます。漏洩を防ぐために、本ガイドのようにルートごと、ロケールごとにネームスペースをロードしてください。

    use-intlはnext-intlのコア部分です。next-intlはその上にNext.js固有の統合(ミドルウェア、ナビゲーションヘルパー、Server Components用のgetTranslations、リクエスト設定など)を追加したものです。TanStack Startではuse-intlを直接使用し、上記のようにTanStack Routerでルーティングを実装します。

    URL内のプレフィックスを使用してください。これにより、各言語バージョンが固有のURLを持ち、検索エンジンがインデックス可能になり、ユーザーが共有できるようになります。明示的な選択を記憶するためにはクッキーも有用であり、ステップ16のリダイレクトミドルウェアで活用されています。

    サーバーとブラウザで異なるタイムゾーンで日付がフォーマットされるためです。両側で同じテキストが生成されるよう、IntlProviderに明示的なtimeZoneを渡す(またはクッキーに保存された訪問者のタイムゾーンを渡す)ようにしてください。

    まず、メッセージをネームスペースごとに分割し、import.meta.globを使用してルートごと・ロケールごとにロードします。これによりロケール漏洩とページ漏洩が解消されます。さらにランタイムサイズを削減したい場合は、@intlayer/use-intlアダプターに切り替えます。ベンチマークにおいて、同じAPIのまま約75.9 KBから約6.7 KBに削減されます。

    ルートローダーから返されたメッセージを使用して、ルートのhead()関数内でcreateTranslatorを呼び出し、title、description、カノニカルリンク、hreflangリンクを返します。ステップ13で再利用可能なヘルパーを提供しています。

    コメント

    まだコメントはありません。最初のコメントを共有しましょう。

    関連記事

    最新の投稿