Skip to content

国際化 (i18n)

国際化(i18n)は、アプリケーションを複数の言語や地域に対応させるための技術です。Next.jsでは、next-intlを使用して国際化を実装できます。

単一言語アプリケーションの課題

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>
);
}
// メリット:
// - 複数の言語に対応可能
// - テキストの管理が容易
// - 言語の切り替えが簡単

メリット:

  1. 多言語対応: 複数の言語と地域に対応
  2. 保守性: テキストの一元管理
  3. ユーザー体験: ユーザーの言語に合わせた表示
  4. SEO: 言語ごとのSEO最適化
Terminal window
npm install next-intl
i18n.ts
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.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n.ts');
export default withNextIntl({
// 他の設定
});

📝 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形式の設定

Terminal window
npm install js-yaml
npm install --save-dev @types/js-yaml

messages/ja.yaml:

welcome: "ようこそ"
home:
description: "これはホームページです"
terms: |
利用規約は以下の通りです。
1. 本サービスは...
2. ユーザーは...
詳細については、お問い合わせください。
login: "ログイン"
user:
greeting: "こんにちは、{name}さん"
items: "{count}個のアイテム"

カスタムローダーの実装:

i18n.ts
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.ts
export 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' }); // ✅ OK
t('items', { count: 5 }); // ✅ OK
// ❌ 型エラー: 存在しないキーはコンパイルエラー
t('wrong.key'); // ❌ 型エラー
t('greeting', { wrongParam: 'value' }); // ❌ 型エラー
// ✅ 型安全な引数
t('greeting', { name: 'Taro' }); // nameは必須
t('items', { count: 5 }); // countは必須

型定義の自動生成(オプション):

next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n.ts', {
experimental: {
createMessagesDeclaration: './messages/ja.json'
}
});
export default withNextIntl({
// 他の設定
});

注意点:

  • TypeScriptのresolveJsonModuleを有効にする必要があります
  • メッセージファイルの構造が変更されたら、型定義も自動的に更新されます
  • YAMLを使用する場合、型安全性の恩恵を受けにくいため、JSONの方が推奨されます(ただし、YAMLから型定義を生成する方法もあります)
middleware.ts
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|.*\\..*).*)'],
};
app/[locale]/page.tsx
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>
);
}
components/UserGreeting.tsx
'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>
);
}
components/LanguageSwitcher.tsx
'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" />;
}
components/FormattedDate.tsx
'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>
);
}
app/[locale]/layout.tsx
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最適化とメタデータの国際化”
app/[locale]/layout.tsx
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}`])
),
},
};
}

メッセージファイルにメタデータを追加:

messages/ja.json
{
"metadata": {
"title": "サイトタイトル",
"description": "サイトの説明文"
}
}

📸 アセット(画像・OGP)の国際化

Section titled “📸 アセット(画像・OGP)の国際化”

言語ごとに異なる画像を表示する方法です。

ディレクトリ構造:

public/
images/
ja/
hero.png
logo.svg
en/
hero.png
logo.svg

使用例:

app/[locale]/page.tsx
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>
);
}

クライアントコンポーネントでの使用:

components/LocalizedImage.tsx
'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}
/>

Next.js 13+のApp Routerでは、opengraph-image.tsxを使用して動的にOGP画像を生成できます。

動的OGP画像の生成:

app/[locale]/opengraph-image.tsx
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画像の国際化:

app/[locale]/opengraph-image.tsx
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画像の指定:

app/[locale]/layout.tsx
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',
},
},
};
}
app/[locale]/icon.tsx
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,
}
);
}
  1. 画像の命名規則: ロケールごとにディレクトリを分けるか、ファイル名にロケールを含める
  2. フォールバック: デフォルトロケールの画像をフォールバックとして使用
  3. パフォーマンス: 必要な画像のみを読み込む(Next.jsのImageコンポーネントを使用)
  4. 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画像の多言語対応

🚨 重要な注意点:

  1. YAML vs JSON: 長文や翻訳者が編集する場合はYAML、型安全性を重視する場合はJSON
  2. 型安全性: Type-safe messages機能を必ず導入し、実行時エラーを防止
  3. アセット管理: 画像やOGP画像も国際化し、SEOを最適化

国際化は、グローバルなアプリケーションにおいて不可欠な機能です。適切に実装することで、世界中のユーザーに適切な体験を提供できます。