SEO//12 読了時間

Remix i18n:Remixアプリケーションの国際化

Eray Gündoğmuş
共有

Remix i18n:Remixアプリケーションの国際化

Remixのサーバーファースト アーキテクチャにより、国際化は純粋なクライアントサイド フレームワークと比べて、より強力かつ独自の方法で実現できます。Remixでは、サーバー上でロケールを検出し、サーバーサイドで翻訳を読み込み、完全にローカライズされたHTMLをクライアントに送信できます。未翻訳コンテンツのちらつきもなく、クライアントサイドのロケール検出による遅延もありません。

このガイドでは、ルーティング、ロケール検出、翻訳の読み込み、そして最も人気のあるRemix用i18nライブラリであるremix-i18nextとの統合を含め、RemixでゼロからI18nを実装する方法を説明します。

Remix i18nが異なる理由

Remixはローダーとアクションにおいてサーバー上でコードを実行し、サーバーとクライアントの両方でコンポーネントをレンダリングします。これによりi18nに重要な違いが生まれます:

サーバーサイドの翻訳読み込み:クライアントサイドのReactアプリでは、翻訳はブラウザによって読み込まれます。Remixでは、翻訳をloader関数で読み込んでコンポーネントに渡すことができます。つまり、サーバーから送信されるHTMLはすでに翻訳済みです。

URLベースのルーティング:Remixのファイルベースのルーティングは、/en/products/fr/productsのようなロケールベースのURLパターンに自然にマッピングされます。

プログレッシブ エンハンスメント:RemixはJavaScriptなしで動作するように設計されています。ローカライズされたルートは、クライアントサイドのハイドレーションなしでも正しくレンダリングされる必要があります。

ネストされたルーティング:Remixのネストされたレイアウトにより、ロケール検出と翻訳コンテキストをルートツリーの上位に確立し、子ルートに継承させることができます。

remix-i18nextのセットアップ

remix-i18nexti18nextの上に構築されており、サーバーサイドとクライアントサイドのi18n向けRemix専用のユーティリティを提供します。

インストール

npm install remix-i18next i18next react-i18next i18next-browser-languagedetector i18next-http-backend

プロジェクト構成

app/
├── locales/
│   ├── en/
│   │   ├── common.json
│   │   └── home.json
│   └── fr/
│       ├── common.json
│       └── home.json
├── i18n.server.ts       # サーバーサイドi18n設定
├── i18n.client.ts       # クライアントサイドi18n設定
├── i18next.server.ts    # remix-i18nextインスタンス
└── root.tsx

サーバーサイド設定

// app/i18n.server.ts
import { RemixI18Next } from 'remix-i18next';
import i18n from './i18n';
import Backend from 'i18next-fs-backend';
import { resolve } from 'node:path';

const i18next = new RemixI18Next({
  detection: {
    supportedLanguages: i18n.supportedLngs,
    fallbackLanguage: i18n.fallbackLng,
  },
  i18next: {
    ...i18n,
    backend: {
      loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
    },
  },
  plugins: [Backend],
});

export default i18next;
// app/i18n.ts - 共有設定
export default {
  supportedLngs: ['en', 'fr', 'de', 'ja', 'ar'],
  fallbackLng: 'en',
  defaultNS: 'common',
  react: { useSuspense: false },
};

ルートルートのセットアップ

// app/root.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import {
  Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData,
} from '@remix-run/react';
import { useTranslation } from 'react-i18next';
import { useChangeLanguage } from 'remix-i18next';
import i18next from '~/i18next.server';
import i18n from '~/i18n';

export async function loader({ request }: LoaderFunctionArgs) {
  const locale = await i18next.getLocale(request);
  return json({ locale });
}

export let handle = { i18n: 'common' };

export default function App() {
  const { locale } = useLoaderData<typeof loader>();
  const { i18n } = useTranslation();

  useChangeLanguage(locale);

  return (
    <html lang={locale} dir={i18n.dir()}>
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

クライアントサイドの初期化

// app/entry.client.tsx
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { RemixBrowser } from '@remix-run/react';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getInitialNamespaces } from 'remix-i18next';
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,
      <StrictMode>
        <RemixBrowser />
      </StrictMode>
    );
  });
}

hydrate();

サーバーサイドエントリ

// app/entry.server.tsx
import { createInstance } from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-fs-backend';
import { renderToReadableStream } from 'react-dom/server';
import { RemixServer } from '@remix-run/react';
import { resolve } from 'node:path';
import i18n from '~/i18n';
import i18next from '~/i18next.server';

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const instance = createInstance();
  const lng = await i18next.getLocale(request);
  const ns = i18next.getRouteNamespaces(remixContext);

  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}>
      <RemixServer context={remixContext} url={request.url} />
    </I18nextProvider>,
    {
      onError(error) { responseStatusCode = 500; },
    }
  );

  responseHeaders.set('Content-Type', 'text/html');
  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

URLベースのロケール ルーティング

一般的なパターンとして、URLにロケールを含める方法があります:/en/products/fr/produits。Remixでは、レイアウトルートを使ってこれを実装します:

app/routes/
├── ($locale).tsx          # ロケールプレフィックスを処理するレイアウトルート
├── ($locale).index.tsx    # ホームページ
├── ($locale).products.tsx # プロダクトページ
└── ($locale).blog.$slug.tsx # ブログ記事
// app/routes/($locale).tsx
import { json, redirect, type LoaderFunctionArgs } from '@remix-run/node';
import { Outlet, useLoaderData } from '@remix-run/react';

const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'ja', 'ar'];

export async function loader({ params, request }: LoaderFunctionArgs) {
  const { locale } = params;

  // ロケールプレフィックスなし:検出してリダイレクト
  if (!locale) {
    const acceptLanguage = request.headers.get('Accept-Language') ?? 'en';
    const preferredLocale = parseAcceptLanguage(acceptLanguage, SUPPORTED_LOCALES);
    return redirect(`/${preferredLocale}`);
  }

  // 無効なロケール:404
  if (!SUPPORTED_LOCALES.includes(locale)) {
    throw new Response('Not Found', { status: 404 });
  }

  return json({ locale });
}

function parseAcceptLanguage(header: string, supported: string[]): string {
  // Accept-Languageヘッダーを解析して最適なマッチを返す
  const languages = header
    .split(',')
    .map(lang => lang.trim().split(';')[0].trim().split('-')[0].toLowerCase());

  for (const lang of languages) {
    if (supported.includes(lang)) return lang;
  }
  return supported[0];
}

export default function LocaleLayout() {
  return <Outlet />;
}

ルートコンポーネントでの翻訳の使用

// app/routes/($locale).products.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { useTranslation } from 'react-i18next';
import i18next from '~/i18next.server';

export let handle = { i18n: ['common', 'products'] };

export async function loader({ request }: LoaderFunctionArgs) {
  const t = await i18next.getFixedT(request, 'products');

  // SEOに重要なコンテンツにはサーバーサイドのt()を使用
  const title = t('page.title');
  const description = t('page.description');

  return json({ title, description });
}

export function meta({ data }: MetaArgs) {
  return [
    { title: data?.title },
    { name: 'description', content: data?.description },
  ];
}

export default function ProductsPage() {
  const { t } = useTranslation('products');
  const { t: tc } = useTranslation('common');

  return (
    <main>
      <h1>{t('page.title')}</h1>
      <p>{t('page.description')}</p>
      <a href="/">{tc('nav.home')}</a>
    </main>
  );
}

言語切り替えコンポーネント

// app/components/LanguageSwitcher.tsx
import { Link, useLocation, useParams } from '@remix-run/react';

const LANGUAGES = [
  { code: 'en', label: 'English' },
  { code: 'fr', label: 'Français' },
  { code: 'de', label: 'Deutsch' },
  { code: 'ja', label: '日本語' },
  { code: 'ar', label: 'العربية' },
];

export function LanguageSwitcher() {
  const { locale = 'en' } = useParams();
  const { pathname } = useLocation();

  // パス内の現在のロケールを置き換える
  const getLocalePath = (newLocale: string) => {
    return pathname.replace(`/${locale}`, `/${newLocale}`);
  };

  return (
    <nav aria-label="Language selection">
      {LANGUAGES.map(({ code, label }) => (
        <Link
          key={code}
          to={getLocalePath(code)}
          aria-current={code === locale ? 'page' : undefined}
          hrefLang={code}
        >
          {label}
        </Link>
      ))}
    </nav>
  );
}

RTLサポート

アラビア語やヘブライ語の場合、<html>要素にdir属性を設定します:

// app/root.tsx
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];

export default function App() {
  const { locale } = useLoaderData<typeof loader>();
  const { i18n } = useTranslation();
  const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';

  return (
    <html lang={locale} dir={dir}>
      {/* ... */}
    </html>
  );
}

詳細なCSSの実装についてはCSSとReactでのRTLサポートを参照してください。

SEOの考慮事項

多言語Remixアプリのために、SEOには以下が必要です:

<head>内のhreflang alternateタグ

export function meta({ data, location }: MetaArgs) {
  const baseUrl = 'https://example.com';
  const pathWithoutLocale = location.pathname.replace(/^\/(en|fr|de|ja|ar)/, '');

  return [
    { title: data?.title },
    // hreflang alternateリンク
    { tagName: 'link', rel: 'alternate', hrefLang: 'en', href: `${baseUrl}/en${pathWithoutLocale}` },
    { tagName: 'link', rel: 'alternate', hrefLang: 'fr', href: `${baseUrl}/fr${pathWithoutLocale}` },
    { tagName: 'link', rel: 'alternate', hrefLang: 'x-default', href: `${baseUrl}/en${pathWithoutLocale}` },
  ];
}

包括的なローカリゼーションSEO戦略についてはlocalization SEOを参照してください。

Remixにおける複数形

i18next_one_other_zeroのキーサフィックスを通じて複数形を処理します:

// public/locales/en/common.json
{
  "items_one": "{{count}} item",
  "items_other": "{{count}} items",
  "items_zero": "No items"
}
const { t } = useTranslation();
t('items', { count: 0 });   // "No items"
t('items', { count: 1 });   // "1 item"
t('items', { count: 42 });  // "42 items"

複雑な複数形のルールを持つ言語(ロシア語は4形式、アラビア語は6形式)については、i18nextがCLDRの複数形ルールを自動的に使用します。詳細は各言語の複数形ルールを参照してください。

パフォーマンス最適化

名前空間の分割handle.i18nエクスポートを使用して、ルートごとに必要な名前空間のみを読み込みます。大きな翻訳バンドルは初期読み込みを遅くします。

静的生成:コンテンツの多いサイトでは、Remixの静的生成またはCloudflare Pagesを使用して、事前レンダリングされたローカライズされたページを配信します。

翻訳のキャッシュ:本番環境では、CDNレベルで翻訳ファイルをキャッシュします。翻訳ファイルはほとんど変更されないため、積極的なキャッシュの候補として適しています。

名前空間の遅延読み込み:フォールドより下のコンテンツについては、クライアント上で補助的な名前空間を遅延読み込みします。


better-i18nでアプリをグローバルに展開しましょう

better-i18nは、AI駆動の翻訳、gitネイティブなワークフロー、グローバルCDN配信を1つの開発者ファーストプラットフォームに統合しています。スプレッドシートの管理をやめて、すべての言語でリリースを始めましょう。

無料で始める → · 機能を探る · ドキュメントを読む

Comments

Loading comments...