目次
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-i18nextはi18nextの上に構築されており、サーバーサイドとクライアントサイドの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つの開発者ファーストプラットフォームに統合しています。スプレッドシートの管理をやめて、すべての言語でリリースを始めましょう。