国際化 (i18n)
国際化 (i18n)
Section titled “国際化 (i18n)”国際化(i18n)は、アプリケーションを複数の言語や地域に対応させるための技術です。Next.jsでは、next-intlを使用して国際化を実装できます。
なぜ国際化が必要なのか
Section titled “なぜ国際化が必要なのか”単一言語アプリケーションの課題
Section titled “単一言語アプリケーションの課題”問題のある単一言語アプリケーション:
// ハードコードされた日本語テキストexport default function HomePage() { return ( <div> <h1>ようこそ</h1> <p>これはホームページです</p> <button>ログイン</button> </div> );}
// 問題点:// - 英語圏のユーザーには理解できない// - 多言語対応が困難// - テキストの変更が困難国際化の解決:
// 翻訳キーを使用export default function HomePage() { const t = useTranslations();
return ( <div> <h1>{t('welcome')}</h1> <p>{t('home.description')}</p> <button>{t('login')}</button> </div> );}
// メリット:// - 複数の言語に対応可能// - テキストの管理が容易// - 言語の切り替えが簡単メリット:
- 多言語対応: 複数の言語と地域に対応
- 保守性: テキストの一元管理
- ユーザー体験: ユーザーの言語に合わせた表示
- SEO: 言語ごとのSEO最適化
next-intlの設定
Section titled “next-intlの設定”依存関係の追加
Section titled “依存関係の追加”npm install next-intl設定ファイルの作成
Section titled “設定ファイルの作成”import { notFound } from 'next/navigation';import { getRequestConfig } from 'next-intl/server';
export const locales = ['ja', 'en', 'zh'] as const;export type Locale = (typeof locales)[number];
export default getRequestConfig(async ({ locale }) => { if (!locales.includes(locale as Locale)) { notFound(); }
return { messages: (await import(`./messages/${locale}.json`)).default, };});Next.js設定の更新
Section titled “Next.js設定の更新”import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n.ts');
export default withNextIntl({ // 他の設定});メッセージファイルの作成
Section titled “メッセージファイルの作成”📝 JSON形式(基本的な方法)
messages/ja.json:
{ "welcome": "ようこそ", "home": { "description": "これはホームページです" }, "login": "ログイン", "user": { "greeting": "こんにちは、{name}さん", "items": "{count}個のアイテム" }}messages/en.json:
{ "welcome": "Welcome", "home": { "description": "This is the home page" }, "login": "Login", "user": { "greeting": "Hello, {name}", "items": "{count} items" }}🚨 マサカリ2:JSONではなく「YAML」を検討すべき
実務では翻訳テキストが長文(利用規約など)になることがあります。JSONだと改行の扱いが苦痛ですが、YAMLならブロック構文でスッキリ書けます。
メリット:
- 長文の管理が容易(ブロック構文)
- 可読性が高い
- エンジニア以外(翻訳者)も編集しやすい
YAML形式の設定
npm install js-yamlnpm install --save-dev @types/js-yamlmessages/ja.yaml:
welcome: "ようこそ"home: description: "これはホームページです" terms: | 利用規約は以下の通りです。
1. 本サービスは... 2. ユーザーは...
詳細については、お問い合わせください。login: "ログイン"user: greeting: "こんにちは、{name}さん" items: "{count}個のアイテム"カスタムローダーの実装:
import { notFound } from 'next/navigation';import { getRequestConfig } from 'next-intl/server';import { readFileSync } from 'fs';import { join } from 'path';import yaml from 'js-yaml';
export const locales = ['ja', 'en', 'zh'] as const;export type Locale = (typeof locales)[number];
async function loadYamlMessages(locale: Locale) { const filePath = join(process.cwd(), 'messages', `${locale}.yaml`); const fileContents = readFileSync(filePath, 'utf8'); return yaml.load(fileContents) as Record<string, unknown>;}
export default getRequestConfig(async ({ locale }) => { if (!locales.includes(locale as Locale)) { notFound(); }
return { messages: await loadYamlMessages(locale as Locale), };});💡 シニアのアドバイス
「読みやすさ」と「管理コスト」を考え、エンジニア以外(翻訳者)も触るならYAMLを推奨すべきです。特に、利用規約やプライバシーポリシーなどの長文コンテンツがある場合は、YAMLの方がはるかに管理しやすくなります。
🚨 マサカリ3:型安全性が皆無
Section titled “🚨 マサカリ3:型安全性が皆無”現在のコードではt('wrong.key')とタイポしても実行時までエラーに気づけません。next-intlのType-safe messages機能を導入し、messages/ja.jsonの構造を自動で型定義に落とし込む設定を追記すべきです。
型安全性の設定
// global.d.ts(または型定義ファイル)import { routing } from './i18n/routing';import messages from './messages/ja.json';
declare module 'next-intl' { interface AppConfig { Locale: (typeof routing.locales)[number]; Messages: typeof messages; }}
// i18n/routing.tsexport const routing = { locales: ['ja', 'en', 'zh'] as const, defaultLocale: 'ja' as const,};tsconfig.jsonの設定:
{ "compilerOptions": { "resolveJsonModule": true, "esModuleInterop": true }}型安全な使用例:
// ✅ 型安全: 存在するキーは補完されるconst t = useTranslations('user');t('greeting', { name: 'Taro' }); // ✅ OKt('items', { count: 5 }); // ✅ OK
// ❌ 型エラー: 存在しないキーはコンパイルエラーt('wrong.key'); // ❌ 型エラーt('greeting', { wrongParam: 'value' }); // ❌ 型エラー
// ✅ 型安全な引数t('greeting', { name: 'Taro' }); // nameは必須t('items', { count: 5 }); // countは必須型定義の自動生成(オプション):
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n.ts', { experimental: { createMessagesDeclaration: './messages/ja.json' }});
export default withNextIntl({ // 他の設定});注意点:
- TypeScriptの
resolveJsonModuleを有効にする必要があります - メッセージファイルの構造が変更されたら、型定義も自動的に更新されます
- YAMLを使用する場合、型安全性の恩恵を受けにくいため、JSONの方が推奨されます(ただし、YAMLから型定義を生成する方法もあります)
ミドルウェアの設定
Section titled “ミドルウェアの設定”import createMiddleware from 'next-intl/middleware';import { locales } from './i18n';
export default createMiddleware({ locales, defaultLocale: 'ja', localePrefix: 'as-needed', // デフォルト言語の場合はプレフィックスなし});
export const config = { matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],};ページでの使用
Section titled “ページでの使用”サーバーコンポーネント
Section titled “サーバーコンポーネント”import { useTranslations } from 'next-intl';import { getTranslations } from 'next-intl/server';
export default async function HomePage({ params: { locale },}: { params: { locale: string };}) { const t = await getTranslations('home');
return ( <div> <h1>{t('welcome')}</h1> <p>{t('description')}</p> </div> );}クライアントコンポーネント
Section titled “クライアントコンポーネント”'use client';
import { useTranslations } from 'next-intl';
export default function UserGreeting({ name }: { name: string }) { const t = useTranslations('user');
return ( <div> <p>{t('greeting', { name })}</p> <p>{t('items', { count: 5 })}</p> </div> );}言語の切り替え
Section titled “言語の切り替え”'use client';
import { useLocale } from 'next-intl';import { useRouter, usePathname } from 'next/navigation';import { useTransition } from 'react';
export default function LanguageSwitcher() { const locale = useLocale(); const router = useRouter(); const pathname = usePathname(); const [isPending, startTransition] = useTransition();
const switchLocale = (newLocale: string) => { startTransition(() => { // パスから現在のロケールを削除し、新しいロケールに置き換え const segments = pathname.split('/').filter(Boolean); const currentLocaleIndex = segments.findIndex(seg => ['ja', 'en', 'zh'].includes(seg));
if (currentLocaleIndex !== -1) { segments[currentLocaleIndex] = newLocale; } else { segments.unshift(newLocale); }
router.push(`/${segments.join('/')}`); }); };
return ( <select value={locale} onChange={(e) => switchLocale(e.target.value)} disabled={isPending} > <option value="ja">日本語</option> <option value="en">English</option> <option value="zh">中文</option> </select> );}動的なロケールの取得:
// useLocaleはクライアントコンポーネントでのみ使用可能'use client';
import { useLocale } from 'next-intl';
export default function MyComponent() { const locale = useLocale(); // 'ja' | 'en' | 'zh'
// ロケールに基づいて条件分岐 const imagePath = `/images/${locale}/hero.png`;
return <img src={imagePath} alt="Hero" />;}
// サーバーコンポーネントでは、paramsから取得export default async function MyPage({ params: { locale },}: { params: { locale: string };}) { const imagePath = `/images/${locale}/hero.png`;
return <img src={imagePath} alt="Hero" />;}日付と数値のフォーマット
Section titled “日付と数値のフォーマット”'use client';
import { useFormatter } from 'next-intl';
export default function FormattedDate({ date }: { date: Date }) { const format = useFormatter();
return ( <div> <p>{format.dateTime(date, { dateStyle: 'long' })}</p> <p>{format.number(1234.56, { style: 'currency', currency: 'JPY' })}</p> <p>{format.relativeTime(date)}</p> </div> );}実践的な例: 多言語対応アプリ
Section titled “実践的な例: 多言語対応アプリ”import { NextIntlClientProvider } from 'next-intl';import { getMessages } from 'next-intl/server';import { notFound } from 'next/navigation';import { locales } from '@/i18n';
export function generateStaticParams() { return locales.map((locale) => ({ locale }));}
export default async function LocaleLayout({ children, params: { locale },}: { children: React.ReactNode; params: { locale: string };}) { if (!locales.includes(locale as any)) { notFound(); }
const messages = await getMessages();
return ( <html lang={locale}> <body> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> </body> </html> );}SEO最適化とメタデータの国際化
Section titled “SEO最適化とメタデータの国際化”import { getTranslations } from 'next-intl/server';import { locales } from '@/i18n/routing';
export async function generateMetadata({ params: { locale },}: { params: { locale: string };}) { const t = await getTranslations({ locale, namespace: 'metadata' });
return { title: t('title'), description: t('description'), alternates: { languages: Object.fromEntries( locales.map((loc) => [loc, `/${loc}`]) ), }, };}メッセージファイルにメタデータを追加:
{ "metadata": { "title": "サイトタイトル", "description": "サイトの説明文" }}📸 アセット(画像・OGP)の国際化
Section titled “📸 アセット(画像・OGP)の国際化”画像の国際化
Section titled “画像の国際化”言語ごとに異なる画像を表示する方法です。
1. 静的画像の国際化
Section titled “1. 静的画像の国際化”ディレクトリ構造:
public/ images/ ja/ hero.png logo.svg en/ hero.png logo.svg使用例:
import Image from 'next/image';import { getTranslations } from 'next-intl/server';
export default async function HomePage({ params: { locale },}: { params: { locale: string };}) { const t = await getTranslations();
// ロケールに基づいて画像パスを生成 const heroImage = `/images/${locale}/hero.png`; const logoImage = `/images/${locale}/logo.svg`;
return ( <div> <Image src={logoImage} alt={t('logo.alt')} width={200} height={50} /> <Image src={heroImage} alt={t('hero.alt')} width={1200} height={600} /> </div> );}クライアントコンポーネントでの使用:
'use client';
import Image from 'next/image';import { useLocale } from 'next-intl';
interface LocalizedImageProps { src: string; // ロケール部分を除いたパス(例: '/images/hero.png') alt: string; width: number; height: number;}
export default function LocalizedImage({ src, alt, width, height,}: LocalizedImageProps) { const locale = useLocale();
// パスから拡張子を分離 const [basePath, extension] = src.split('.'); const localizedSrc = `${basePath}/${locale}.${extension}`;
return ( <Image src={localizedSrc} alt={alt} width={width} height={height} /> );}
// 使用例<LocalizedImage src="/images/hero.png" alt="Hero image" width={1200} height={600}/>2. OGP(Open Graph)画像の国際化
Section titled “2. OGP(Open Graph)画像の国際化”Next.js 13+のApp Routerでは、opengraph-image.tsxを使用して動的にOGP画像を生成できます。
動的OGP画像の生成:
import { ImageResponse } from 'next/og';import { getTranslations } from 'next-intl/server';
export const runtime = 'edge';export const alt = 'OG Image';export const size = { width: 1200, height: 630,};
export default async function OpenGraphImage({ params: { locale },}: { params: { locale: string };}) { const t = await getTranslations({ locale, namespace: 'og' });
return new ImageResponse( ( <div style={{ fontSize: 60, background: 'white', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px', }} > <h1>{t('title')}</h1> <p>{t('description')}</p> </div> ), { ...size, } );}静的OGP画像の国際化:
import { ImageResponse } from 'next/og';import { ImageResponse as StaticImageResponse } from 'next/server';import { readFileSync } from 'fs';import { join } from 'path';
export const runtime = 'edge';export const alt = 'OG Image';export const size = { width: 1200, height: 630,};
export default async function OpenGraphImage({ params: { locale },}: { params: { locale: string };}) { // ロケールに基づいて画像を読み込み const imagePath = join(process.cwd(), 'public', 'og', `${locale}.png`); const imageBuffer = readFileSync(imagePath);
return new ImageResponse(imageBuffer, { ...size, });}メタデータでのOGP画像の指定:
import { getTranslations } from 'next-intl/server';
export async function generateMetadata({ params: { locale },}: { params: { locale: string };}) { const t = await getTranslations({ locale, namespace: 'metadata' });
return { title: t('title'), description: t('description'), openGraph: { title: t('title'), description: t('description'), images: [ { url: `/og/${locale}.png`, // または動的に生成された画像 width: 1200, height: 630, alt: t('og.alt'), }, ], locale: locale, type: 'website', }, twitter: { card: 'summary_large_image', title: t('title'), description: t('description'), images: [`/og/${locale}.png`], }, alternates: { languages: { ja: '/ja', en: '/en', zh: '/zh', }, }, };}3. ファビコンの国際化
Section titled “3. ファビコンの国際化”import { ImageResponse } from 'next/og';
export const size = { width: 32, height: 32,};export const contentType = 'image/png';
export default async function Icon({ params: { locale },}: { params: { locale: string };}) { // ロケールに基づいてファビコンを生成 return new ImageResponse( ( <div style={{ fontSize: 24, background: 'transparent', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', }} > {locale.toUpperCase()} </div> ), { ...size, } );}ベストプラクティス
Section titled “ベストプラクティス”- 画像の命名規則: ロケールごとにディレクトリを分けるか、ファイル名にロケールを含める
- フォールバック: デフォルトロケールの画像をフォールバックとして使用
- パフォーマンス: 必要な画像のみを読み込む(Next.jsのImageコンポーネントを使用)
- OGP画像: 動的生成と静的画像の使い分け(パフォーマンスと柔軟性のバランス)
// フォールバック付きの画像読み込みfunction getLocalizedImage(src: string, locale: string, fallbackLocale = 'ja') { const localizedSrc = src.replace('{locale}', locale);
// 画像が存在するか確認(実際の実装では、存在チェックが必要) return localizedSrc || src.replace('{locale}', fallbackLocale);}Next.jsで国際化を実装するポイント:
- next-intl: 国際化ライブラリ
- メッセージファイル: 言語ごとの翻訳ファイル(JSONまたはYAML)
- YAML: 長文コンテンツや翻訳者が編集する場合に推奨
- JSON: 型安全性が高い、エンジニアが編集する場合に推奨
- 型安全性: Type-safe messages機能で型エラーを防止
- ミドルウェア: 言語の自動検出とルーティング
- 日付・数値フォーマット: 地域に応じたフォーマット
- SEO最適化: 言語ごとのメタデータ
- アセットの国際化: 画像やOGP画像の多言語対応
🚨 重要な注意点:
- YAML vs JSON: 長文や翻訳者が編集する場合はYAML、型安全性を重視する場合はJSON
- 型安全性: Type-safe messages機能を必ず導入し、実行時エラーを防止
- アセット管理: 画像やOGP画像も国際化し、SEOを最適化
国際化は、グローバルなアプリケーションにおいて不可欠な機能です。適切に実装することで、世界中のユーザーに適切な体験を提供できます。