言語を切り替える
テーマを切り替える

Next.js 国際化と静的生成:SSG 多言語サイト構築の実践ガイド

Next.js App Router で初めて多言語対応の静的生成(SSG)に挑戦したとき、正直言ってかなり苦戦しました。
ドキュメント通りに設定したはずなのにビルドエラーが出たり、なんとか成功したと思ったら全ページの生成に15分もかかったり…。

あなたもこんな経験はありませんか?

こんな問題に直面していませんか?

シナリオ1:ビルド時の謎のエラー

意気揚々と npm run build を実行したら、ターミナルにこんなエラーが表示される。

Error: Page "/en/about" is missing `generateStaticParams()`
so it cannot be used with `output: "export"`.

「え? next.config.jsi18n 設定したはずだけど?」と焦りますが、実は App Router と Pages Router では国際化の仕組みが全く異なるため、古い設定は通用しません。

シナリオ2:ビルド時間が長すぎる

私のプロジェクトでは6言語対応で、各言語50ページほどありました。合計300ページ。大規模とは言えませんが、ビルド結果は…

 Generating static pages (152/152) - 15m 32s

15分!ちょっとした修正のたびにこれでは、開発体験は最悪です。「これで本番運用の CI/CD 回せるのか?」と不安になりました。

シナリオ3:翻訳が更新されない

最も厄介なのがこれです。ja.json を更新して再ビルド・デプロイしたのに、サイト上では古い翻訳のまま。ブラウザのキャッシュをクリアしないと反映されないなんて、本番環境では許されません。

問題の根本原因

これらの問題の背後には、いくつかの技術的な理由があります。

  1. Pages Router の i18n 設定は無効 - App Router は next.config.jsi18n フィールドを無視します。
  2. 静的エクスポートと動的レンダリングの競合 - output: 'export' を使う場合、ビルド時に全ルートが確定している必要があります。cookies()headers() などの動的 API を使うとエラーになります。
  3. 翻訳ファイルのキャッシュ - Next.js はインポートされた JSON ファイルをキャッシュするため、開発中に更新が即座に反映されないことがあります。

この記事では、これらの落とし穴を避け、Next.js App Router で正しく、かつ効率的に多言語 SSG を実装する方法を解説します。

App Router における i18n の新パラダイム

コーディングを始める前に、App Router の国際化アプローチを理解しておきましょう。

Pages Router vs App Router:全く異なるアプローチ

機能Pages RouterApp Router
設定方法next.config.jsi18nミドルウェア + 動的ルート [lang]
URL構造/en/, /ja/ を自動付与app/[lang]/page.tsx を手動作成
静的生成getStaticPathsgenerateStaticParams
翻訳読込serverSideTranslationsServer Components で直接 import

generateStaticParams とは?

App Router の核心となる関数です。Next.js に対して「このパラメータの組み合わせで静的ページを作ってくれ」と指示を出します。

// app/[lang]/layout.tsx
export async function generateStaticParams() {
  // プリレンダリングしたい言語リストを返す
  return [
    { lang: 'en' },
    { lang: 'ja' },
    { lang: 'zh' }
  ]
}

ビルド時にこの関数が実行され、以下のような静的ファイルが出力されます:

out/
├── en/
│   └── index.html
├── ja/
│   └── index.html
└── zh/
    └── index.html

重要:関数名は厳密に generateStaticParams である必要があります。

翻訳ファイルの読み込み方

Pages Router 時代は next-i18next が主流でしたが、App Router ではシンプルに Server Components で JSON をインポートできます。

// Server Component 内
import enTranslations from '@/i18n/locales/en/common.json'
import jaTranslations from '@/i18n/locales/ja/common.json'

const translations = {
  'en': enTranslations,
  'ja': jaTranslations,
}

export default function Page({ params }: { params: { lang: string } }) {
  const t = translations[params.lang]
  return <h1>{t.title}</h1>
}

ただし、これでは全言語の翻訳がバンドルに含まれてしまうため、実用的には動的インポートを用いるのが一般的です。

実践:ゼロから作る多言語 SSG プロジェクト

では、実際にコードを書いていきましょう。

手順1:ディレクトリ構造の設計

私が推奨する構造はこちらです:

app/
├── [lang]/                    # 言語動的ルート(必須)
│   ├── layout.tsx            # generateStaticParams を配置
│   ├── page.tsx              # トップページ
│   ├── about/
│   │   └── page.tsx
│   └── blog/
│       ├── page.tsx
│       └── [slug]/
│           └── page.tsx      # ネストされた動的ルート
├── i18n/
│   ├── locales/              # 翻訳ファイル
│   │   ├── en/...
│   │   ├── ja/...
│   │   └── zh-CN/...
│   ├── config.ts             # 設定ファイル
│   └── utils.ts              # ユーティリティ
└── middleware.ts             # 言語リダイレクト用

手順2:i18n 設定ファイル

// i18n/config.ts
export const i18nConfig = {
  // サポート言語
  locales: ['en', 'ja', 'zh-CN'],
  // デフォルト言語
  defaultLocale: 'en',
  
  // 【重要】ビルド時間の最適化:主要言語のみプリレンダリングする
  localesToPrerender: process.env.NODE_ENV === 'production'
    ? ['en', 'ja']     // 本番は英語と日本語のみ先に生成
    : ['en'],          // 開発時は英語のみ
} as const

export type Locale = (typeof i18nConfig)['locales'][number]
export const namespaces = ['common', 'home', 'blog'] as const

ポイントlocalesToPrerender を活用することで、マイナー言語はアクセス時にオンデマンド生成(ISR)する戦略が取れます。

手順3:翻訳ローダーの実装

// i18n/utils.ts
import type { Locale, Namespace } from './config'

const translationsCache = new Map<string, any>()

export async function loadTranslations(
  locale: Locale,
  namespaces: Namespace[]
) {
  const translations: Record<string, any> = {}

  for (const namespace of namespaces) {
    const cacheKey = `${locale}-${namespace}`

    if (!translationsCache.has(cacheKey)) {
      try {
        const translation = await import(
          `@/i18n/locales/${locale}/${namespace}.json`
        )
        translationsCache.set(cacheKey, translation.default)
      } catch (error) {
        console.warn(`⚠️ Translation file not found: ${locale}/${namespace}`)
        translationsCache.set(cacheKey, {})
      }
    }
    translations[namespace] = translationsCache.get(cacheKey)
  }
  return translations
}

手順4:ルートレイアウト(最重要)

// app/[lang]/layout.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'

/**
 * 【核心】静的パラメータの生成
 * ビルド時に実行され、返す配列に基づいてページが生成されます
 */
export async function generateStaticParams() {
  console.log(`🌍 Generating static params for ${i18nConfig.localesToPrerender.length} locales...`)

  return i18nConfig.localesToPrerender.map((locale) => ({
    lang: locale, // [lang] フォルダ名と一致させること
  }))
}

export default async function RootLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: { lang: string }
}) {
  const translations = await loadTranslations(params.lang as Locale, ['common'])

  return (
    <html lang={params.lang} dir={params.lang === 'ar' ? 'rtl' : 'ltr'}>
      <body>{children}</body>
    </html>
  )
}

注意点
generateStaticParams で返すオブジェクトのキー名は、フォルダ名 [lang] と一致させる必要があります(例:{ lang: 'en' })。{ locale: 'en' } だと動きません。

また、レイアウト内で cookies() などの動的 API を使わないように注意してください。使うと静的生成が失敗します。

手順5:ネストされた動的ルートの処理

ブログ記事などの詳細ページも静的生成する場合、言語 × 記事 の組み合わせを生成する必要があります。

// app/[lang]/blog/[slug]/page.tsx

// 記事スラッグ取得関数(ダミー)
async function getBlogSlugs() {
  return ['getting-started', 'advanced-tips']
}

export async function generateStaticParams() {
  // 全記事のスラッグを取得
  const slugs = await getBlogSlugs()

  // 言語 × 記事 の組み合わせを生成
  // flatMap を使って配列をフラット化
  return i18nConfig.localesToPrerender.flatMap((locale) =>
    slugs.map((slug) => ({
      lang: locale,
      slug: slug,
    }))
  )
}

export default async function BlogPost({
  params,
}: {
  params: { lang: string; slug: string }
}) {
  // コンポーネントロジック...
}

パフォーマンス最適化のコツ
ループの中で毎回データフェッチを行うのではなく、一度全データを取得してからメモリ上で組み合わせを作るのが定石です。これでビルド時間が大幅に短縮されます。

手順6:Middleware による言語検出

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { i18nConfig } from './i18n/config'

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  
  // 既に言語プレフィックスがあるか確認
  const hasLocale = i18nConfig.locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
  if (hasLocale) return

  // 言語検出ロジック(簡略版)
  // 実際は Accept-Language ヘッダーや Cookie を解析します
  const locale = i18nConfig.defaultLocale

  request.nextUrl.pathname = `/${locale}${pathname}`
  return NextResponse.redirect(request.nextUrl)
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

パフォーマンス最適化:ビルド時間を爆速にする

サイト規模が大きくなるとビルド時間が問題になります。以下のテクニックで最適化しましょう。

1. 選択的プリレンダリング

全言語・全ページをビルド時に生成する必要はありません。アクセス頻度の高い主要言語(例:英語、日本語)だけ generateStaticParams に含め、他は含めないようにします。

含まれていないページへのリクエストが来たらどうなるか?
export const dynamicParams = true(デフォルト)であれば、初回アクセス時にサーバーサイドで生成・キャッシュされます(ISRのような挙動)。

2. データ取得の並列化

ページコンポーネント内で複数のデータを取得する場合、Promise.all を使って並列化しましょう。

const [translations, post] = await Promise.all([
  loadTranslations(params.lang, ['blog']),
  getBlogPost(params.slug)
])

まとめ

Next.js App Router での多言語 SSG は、正しく設定すれば非常に強力です。

  1. generateStaticParams で静的生成する言語とルートを明示する。
  2. 動的 API(Cookieなど)を静的ページで使わない。
  3. 選択的プリレンダリング でビルド時間をコントロールする。

これらのポイントを押さえれば、高速でSEOに強い多言語サイトが構築できます。

FAQ

generateStaticParams に含まれない言語はどうなりますか?
デフォルト設定(dynamicParams = true)であれば、初回リクエスト時にサーバー上でオンデマンド生成され、その後キャッシュされます。これにより、ビルド時間を短縮しつつ全言語をサポートできます。
静的エクスポート(output: 'export')で動的機能は使えますか?
基本的には使えません。headers() や cookies() を使うとビルドエラーになります。クライアントサイドで処理するか、ミドルウェアで対応する必要があります。
翻訳ファイルを更新しても反映されません
Next.js のデータキャッシュが効いている可能性があります。開発中はキャッシュを無効にするか、ビルドキャッシュ(.nextフォルダ)を削除して再起動してみてください。

3 min read · 公開日: 2025年12月25日 · 更新日: 2026年1月22日

コメント

GitHubアカウントでログインしてコメントできます

関連記事