概要
この記事では、Next.jsアプリケーションに多言語対応(国際化、i18n)を実装する方法を解説します。next-i18nextライブラリを使用することで、翻訳ファイルの管理、自動言語検出、ロケール切り替えなどの機能を簡単に実装できます。
国際化(Internationalization、略してi18n)とは、アプリケーションを複数の言語や地域に対応させるための設計手法です。これにより、グローバルなユーザーに最適化されたエクスペリエンスを提供できます。
実装方法の選択
| 方式 | 対象 | 特徴 |
|---|---|---|
| next-i18next | Pages Router | 成熟したエコシステム、豊富なドキュメント |
| next-intl | App Router | App Router向け設計、Server Components対応 |
| 組み込みi18n | 両方 | ルーティングのみ、翻訳機能は別途必要 |
Pages Routerでの実装(next-i18next)
必要な依存関係のインストール
まず、必要なパッケージをインストールします。
npm install next-i18next i18next react-i18next
設定ファイルの追加
プロジェクトのルートディレクトリにnext-i18next.config.jsを作成します。
// next-i18next.config.js
module.exports = {
i18n: {
locales: ['ja', 'en', 'zh'], // 対応する言語
defaultLocale: 'ja', // デフォルトの言語
localeDetection: true, // ブラウザ言語の自動検出
},
localePath: './public/locales', // 翻訳ファイルのパス
reloadOnPrerender: process.env.NODE_ENV === 'development',
};
next.config.jsに追加して多言語対応を有効にします。
// next.config.js
const { i18n } = require('./next-i18next.config');
module.exports = {
i18n,
// 他の設定
};
翻訳ファイルの作成
public/localesディレクトリ内に各ロケールごとのJSONファイルを用意します。
public/
locales/
ja/
common.json
home.json
en/
common.json
home.json
zh/
common.json
home.json
名前空間(namespace)ごとにファイルを分割することで、大規模なアプリケーションでも管理しやすくなります。
// public/locales/ja/common.json
{
"header": {
"home": "ホーム",
"about": "会社概要",
"contact": "お問い合わせ"
},
"footer": {
"copyright": "© {{year}} 株式会社Example",
"privacy": "プライバシーポリシー"
}
}
// public/locales/en/common.json
{
"header": {
"home": "Home",
"about": "About Us",
"contact": "Contact"
},
"footer": {
"copyright": "© {{year}} Example Inc.",
"privacy": "Privacy Policy"
}
}
_app.jsの設定
// pages/_app.js
import { appWithTranslation } from 'next-i18next';
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}
export default appWithTranslation(MyApp);
翻訳の使用
コンポーネント内でuseTranslationフックを使用します。
// components/Header.tsx
import { useTranslation } from 'next-i18next';
import Link from 'next/link';
export default function Header() {
const { t } = useTranslation('common');
return (
<header>
<nav>
<Link href="/">{t('header.home')}</Link>
<Link href="/about">{t('header.about')}</Link>
<Link href="/contact">{t('header.contact')}</Link>
</nav>
</header>
);
}
サーバーサイド翻訳
getStaticPropsやgetServerSidePropsで翻訳データを取得します。
// pages/index.tsx
import { GetStaticProps } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useTranslation } from 'next-i18next';
import Header from '@/components/Header';
export default function Home() {
const { t } = useTranslation('home');
return (
<>
<Header />
<main>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</main>
</>
);
}
export const getStaticProps: GetStaticProps = async ({ locale }) => {
return {
props: {
...(await serverSideTranslations(locale ?? 'ja', ['common', 'home'])),
},
};
};
補間と複数形
変数の補間
翻訳テキストに動的な値を挿入できます。
// public/locales/ja/common.json
{
"greeting": "こんにちは、{{name}}さん",
"itemCount": "{{count}}個のアイテム"
}
const { t } = useTranslation('common');
// 出力: こんにちは、田中さん
<p>{t('greeting', { name: '田中' })}</p>
// 出力: 5個のアイテム
<p>{t('itemCount', { count: 5 })}</p>
複数形の処理
言語によって異なる複数形ルールに対応できます。
// public/locales/en/common.json
{
"item": "{{count}} item",
"item_plural": "{{count}} items"
}
// public/locales/ja/common.json
{
"item": "{{count}}件"
}
// 英語: "1 item" / "5 items"
// 日本語: "1件" / "5件"
<p>{t('item', { count: itemCount })}</p>
ロケール切り替え機能
基本的なロケールスイッチャー
// components/LocaleSwitcher.tsx
import { useRouter } from 'next/router';
import Link from 'next/link';
const localeNames: Record<string, string> = {
ja: '日本語',
en: 'English',
zh: '中文',
};
export default function LocaleSwitcher() {
const { locale, locales, asPath } = useRouter();
return (
<div className="flex gap-2">
{locales?.map((lng) => (
<Link
key={lng}
href={asPath}
locale={lng}
className={`px-3 py-1 rounded ${
locale === lng
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
{localeNames[lng]}
</Link>
))}
</div>
);
}
ドロップダウン形式
// components/LocaleDropdown.tsx
'use client';
import { useRouter } from 'next/router';
import { useState } from 'react';
export default function LocaleDropdown() {
const { locale, locales, push, asPath } = useRouter();
const [isOpen, setIsOpen] = useState(false);
const handleChange = (newLocale: string) => {
push(asPath, asPath, { locale: newLocale });
setIsOpen(false);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-4 py-2 border rounded"
>
<span>{locale?.toUpperCase()}</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<ul className="absolute top-full mt-1 w-full bg-white border rounded shadow-lg">
{locales?.map((lng) => (
<li key={lng}>
<button
onClick={() => handleChange(lng)}
className="w-full px-4 py-2 text-left hover:bg-gray-100"
>
{lng.toUpperCase()}
</button>
</li>
))}
</ul>
)}
</div>
);
}
App Routerでの国際化
App Routerでは、next-i18nextの代わりにnext-intlや独自実装を使用します。
ミドルウェアによるロケール検出
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const locales = ['ja', 'en', 'zh'];
const defaultLocale = 'ja';
function getLocale(request: NextRequest): string {
// Accept-Languageヘッダーから言語を検出
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
const preferred = acceptLanguage.split(',')[0].split('-')[0];
if (locales.includes(preferred)) {
return preferred;
}
}
return defaultLocale;
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// ロケールがパスに含まれているか確認
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return;
// ロケールがない場合はリダイレクト
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
ディレクトリ構造
app/
[locale]/
layout.tsx
page.tsx
about/
page.tsx
dictionaries/
ja.json
en.json
zh.json
辞書の読み込み
// app/dictionaries/index.ts
const dictionaries = {
ja: () => import('./ja.json').then((module) => module.default),
en: () => import('./en.json').then((module) => module.default),
zh: () => import('./zh.json').then((module) => module.default),
};
export type Locale = keyof typeof dictionaries;
export const getDictionary = async (locale: Locale) => {
return dictionaries[locale]();
};
Server Componentsでの使用
// app/[locale]/page.tsx
import { getDictionary, Locale } from '@/app/dictionaries';
interface Props {
params: { locale: Locale };
}
export default async function HomePage({ params: { locale } }: Props) {
const dict = await getDictionary(locale);
return (
<main>
<h1>{dict.home.title}</h1>
<p>{dict.home.description}</p>
</main>
);
}
SEO対応
hreflangタグの設定
// components/LanguageAlternates.tsx
import Head from 'next/head';
import { useRouter } from 'next/router';
export default function LanguageAlternates() {
const { asPath, locales, defaultLocale } = useRouter();
const baseUrl = 'https://example.com';
return (
<Head>
{locales?.map((locale) => (
<link
key={locale}
rel="alternate"
hrefLang={locale}
href={`${baseUrl}${locale === defaultLocale ? '' : `/${locale}`}${asPath}`}
/>
))}
<link
rel="alternate"
hrefLang="x-default"
href={`${baseUrl}${asPath}`}
/>
</Head>
);
}
App Routerでのメタデータ
// app/[locale]/layout.tsx
import { Metadata } from 'next';
import { getDictionary, Locale } from '@/app/dictionaries';
interface Props {
children: React.ReactNode;
params: { locale: Locale };
}
export async function generateMetadata({
params: { locale },
}: Props): Promise<Metadata> {
const dict = await getDictionary(locale);
return {
title: dict.meta.title,
description: dict.meta.description,
alternates: {
canonical: `https://example.com/${locale}`,
languages: {
'ja': 'https://example.com/ja',
'en': 'https://example.com/en',
'zh': 'https://example.com/zh',
},
},
};
}
export default function LocaleLayout({ children, params: { locale } }: Props) {
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}
TypeScript対応
翻訳キーの型安全性を確保できます。
// types/i18next.d.ts
import 'i18next';
import common from '../public/locales/ja/common.json';
import home from '../public/locales/ja/home.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof common;
home: typeof home;
};
}
}
これにより、存在しない翻訳キーを使用した場合にTypeScriptエラーが発生します。
まとめ
next-i18nextを使うことで、Next.jsアプリケーションに簡単に多言語対応を追加できます。ロケールの管理、翻訳ファイルの読み込み、サーバーサイドでの翻訳サポートも自動化されており、スムーズな国際化対応が可能です。App Routerを使用する場合は、ミドルウェアと辞書ファイルを組み合わせた独自実装やnext-intlの使用を検討してください。