Documentation Next.js

概要

この記事では、Next.jsアプリケーションに多言語対応(国際化、i18n)を実装する方法を解説します。next-i18nextライブラリを使用することで、翻訳ファイルの管理、自動言語検出、ロケール切り替えなどの機能を簡単に実装できます。

国際化(Internationalization、略してi18n)とは、アプリケーションを複数の言語や地域に対応させるための設計手法です。これにより、グローバルなユーザーに最適化されたエクスペリエンスを提供できます。

実装方法の選択

方式対象特徴
next-i18nextPages Router成熟したエコシステム、豊富なドキュメント
next-intlApp RouterApp 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>
  );
}

サーバーサイド翻訳

getStaticPropsgetServerSidePropsで翻訳データを取得します。

// 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の使用を検討してください。

参考文献

円