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

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

Next.js App Router プロジェクトで初めて多言語静的生成に挑戦したとき、本当にいろいろな罠にはまりました。ドキュメントどおりに設定したはずなのに build でエラーが出る。なんとかビルドが通っても、全ページ生成に 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 ページをサポートしていました。1 回のビルド結果はこうでした。

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

15 分です。小さな修正のたびにこれだけ待つ開発体験は、正直つらいものでした。「本番 CI/CD だと、いつまで待たされるんだろう」と思いました。

シーン 3:翻訳更新が反映されない

これが一番イライラしたケースです。zh-CN.json を更新して、再ビルド・再デプロイしても、サイトには古い翻訳のまま。ブラウザキャッシュを消さないと新しい内容が見えない。本番環境ではユーザーに期限切れの内容を見せ続けることになりかねません。

問題の根本原因は?

かなり調べて、ようやく本質が見えてきました。

  1. App Router は Pages Router の i18n 設定をサポートしない — 最大の落とし穴です。next.config.jsi18n フィールドは、App Router では無視されます。

  2. 静的エクスポートと動的レンダリングの衝突output: 'export' を設定すると、Next.js はすべてのページをビルド時に確定させる必要があります。cookies()headers() などの動的 API を使うとエラーになります。

  3. 翻訳ファイルのキャッシュ — Next.js は import した JSON をキャッシュします。開発中に翻訳を更新してもキャッシュが残り、最新内容が表示されないことがあります。

同じような問題にぶつかった方のために、Next.js App Router で多言語静的生成を正しく実装し、これらの罠を避ける方法を順を追って説明します。

App Router の i18n 新パラダイムを理解する

コードを書く前に、App Router の国際化の考え方を押さえておきましょう。Pages Router とは本当に違います。

Pages Router vs App Router:まったく別の 2 つのアプローチ

比較表で違いを確認してください。

特性Pages RouterApp Router
設定方法next.config.jsi18n フィールドmiddleware + 動的ルート [lang]
ルート構造/en//zh/ プレフィックスを自動生成app/[lang]/page.tsx を手動作成
静的生成getStaticPaths を使用generateStaticParams を使用
翻訳読み込みserverSideTranslations 関数サーバーコンポーネントで JSON を直接 import

ほぼすべての工程が変わっています。初めてこれに直面したときは、「自分が学んだ Next.js は本物なのか」と思いました。

generateStaticParams とは何か?

App Router の中核概念のひとつです。Next.js に「どのパラメータで静的ページを生成するか」を伝える関数です。

例:

// app/[lang]/layout.tsx
export async function generateStaticParams() {
  // プリレンダリングする言語パラメータをすべて返す
  return [
    { lang: 'en' },
    { lang: 'zh' },
    { lang: 'ja' }
  ]
}

Next.js はビルド時にこの関数を実行し、返されたパラメータごとに静的 HTML を生成します。出力イメージは次のとおりです。

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

ポイントlayout.tsx または page.tsx で定義する必要があり、関数名は generateStaticParams と完全一致(getStaticParams でも generateParams でも不可)です。

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

Pages Router 時代は next-i18nextserverSideTranslations を使いました。App Router ではサーバーコンポーネントで翻訳ファイルを直接 import できます。

// サーバーコンポーネントで直接 import 可能
import enTranslations from '@/i18n/locales/en/common.json'
import zhTranslations from '@/i18n/locales/zh-CN/common.json'

const translations = {
  'en': enTranslations,
  'zh-CN': zhTranslations,
}

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

ただし、全言語の翻訳がバンドルに含まれ、ファイルサイズが膨らむ問題があります。実プロジェクトでは読み込み関数を用意するのが一般的です。

// i18n/utils.ts
export async function loadTranslations(locale: string, namespaces: string[]) {
  const translations: Record<string, any> = {}

  for (const ns of namespaces) {
    try {
      const translation = await import(`@/i18n/locales/${locale}/${ns}.json`)
      translations[ns] = translation.default
    } catch (error) {
      console.warn(`Translation file not found: ${locale}/${ns}`)
      translations[ns] = {}
    }
  }

  return translations
}

必要な翻訳名前空間だけをオンデマンドで読み込めます。

実践:ゼロから多言語 SSG プロジェクトを構築する

理論はここまで。最初から最後まで、完全な多言語静的サイトを一緒に組み立てていきましょう。

ステップ 1:プロジェクト構造の設計

実プロジェクトで使っている、検証済みのディレクトリ構造です。

app/
├── [lang]/                    # 言語動的ルート(中核)
│   ├── layout.tsx            # ルートレイアウト(generateStaticParams を含む)
│   ├── page.tsx              # ホーム
│   ├── about/
│   │   └── page.tsx          # About ページ
│   └── blog/
│       ├── page.tsx          # ブログ一覧
│       └── [slug]/
│           └── page.tsx      # ブログ詳細(ネストした動的ルート)
├── i18n/
│   ├── locales/              # 翻訳ファイル
│   │   ├── en/
│   │   │   ├── common.json   # 共通翻訳
│   │   │   ├── home.json     # ホーム翻訳
│   │   │   └── blog.json     # ブログ翻訳
│   │   ├── zh-CN/
│   │   │   ├── common.json
│   │   │   ├── home.json
│   │   │   └── blog.json
│   │   └── ja/
│   │       ├── common.json
│   │       ├── home.json
│   │       └── blog.json
│   ├── config.ts             # i18n 設定
│   └── utils.ts              # 翻訳ユーティリティ
└── middleware.ts             # 言語検出とリダイレクト

この設計の理由

  1. [lang] フォルダ:動的ルートの中核。URL の言語パラメータがページコンポーネントに渡されます。
  2. 名前空間ごとの翻訳分割:1 ファイルが巨大化するのを防ぎ、ページ機能ごとに分割してオンデマンド読み込み。
  3. config.ts で集中管理:言語関連の設定を一箇所にまとめ、保守しやすく。

ステップ 2:i18n コアファイルの設定

まず設定ファイルから。システム全体の基盤です。

// i18n/config.ts
export const i18nConfig = {
  // サポートする言語一覧
  locales: ['en', 'zh-CN', 'ja'],
  // デフォルト言語
  defaultLocale: 'en',
  // パスプレフィックス戦略
  // 'always': 全言語にプレフィックス /en/、/zh-CN/
  // 'as-needed': デフォルト言語はプレフィックスなし、その他は付与
  localePrefix: 'always',
  // 【重要】主要言語のみプリレンダリング(ビルド時間短縮)
  localesToPrerender: process.env.NODE_ENV === 'production'
    ? ['en', 'zh-CN']  // 本番:英語と中国語のみ
    : ['en'],          // 開発:デフォルト言語のみ
} as const

// TypeScript 型チェック用に型をエクスポート
export type Locale = (typeof i18nConfig)['locales'][number]

// 翻訳名前空間(コード分割用)
export const namespaces = ['common', 'home', 'about', 'blog'] as const
export type Namespace = (typeof namespaces)[number]

ポイント解説

  1. as const:TypeScript の記法。型を広い string[] ではなく正確なリテラル型に固定します。
  2. localesToPrerender:10 言語サポートでも 2 言語だけプリレンダリングすれば、ビルド時間を約 80% 削減できます。残りは ISR やオンデマンド生成で対応。
  3. 名前空間:翻訳 JSON を分割し、初回ロードで巨大ファイルを一括ダウンロードするのを防ぎます。

ステップ 3:翻訳読み込みユーティリティの実装

シンプルで実用的な翻訳ローダーです。

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

// 翻訳ファイルキャッシュ(重複読み込み防止)
const translationsCache = new Map<string, any>()

/**
 * 指定言語の翻訳ファイルを読み込む
 *
 * @param locale 言語コード('en'、'zh-CN' など)
 * @param namespaces 翻訳名前空間の配列(['common', 'home'] など)
 * @returns 翻訳オブジェクト { common: {...}, home: {...} }
 */
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 {
        // 翻訳ファイルを動的 import
        const translation = await import(
          `@/i18n/locales/${locale}/${namespace}.json`
        )
        translationsCache.set(cacheKey, translation.default)
      } catch (error) {
        console.warn(`⚠️ Translation file not found: ${locale}/${namespace}.json`)
        translationsCache.set(cacheKey, {})
      }
    }

    translations[namespace] = translationsCache.get(cacheKey)
  }

  return translations
}

/**
 * 型安全な翻訳関数を生成
 *
 * 使用例:
 * const t = createTranslator(translations)
 * t('common.nav.home')
 * t('home.welcome', { name: 'John' }) // 変数置換対応
 */
export function createTranslator(translations: any) {
  return (key: string, params?: Record<string, string>) => {
    const keys = key.split('.')
    let value = translations

    // ネスト属性へ逐次アクセス
    for (const k of keys) {
      value = value?.[k]
    }

    // 翻訳が見つからない場合は key を返す(デバッグ用)
    if (!value) {
      console.warn(`⚠️ Translation missing: ${key}`)
      return key
    }

    // 変数置換:{{name}} を実際の値に置換
    if (params) {
      return Object.entries(params).reduce(
        (str, [key, val]) => str.replace(`{{${key}}}`, val),
        value
      )
    }

    return value
  }
}

このユーティリティの強み

  1. キャッシュ:初回読み込み後はキャッシュし、ファイルの再読み込みを回避。
  2. エラー処理:翻訳ファイルがなくてもクラッシュせず、警告して空オブジェクトを返す。
  3. 変数置換:翻訳内の {{変数名}} プレースホルダーに対応。
  4. 型フレンドリー:TypeScript と組み合わせて翻訳 key の型安全チェックが可能。

ステップ 4:ルートレイアウトの作成(最重要)

多言語システムの中核ファイルです。

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

/**
 * 【中核】全言語の静的パラメータを生成
 *
 * ビルド時に実行され、Next.js は返値に基づいて静的ページを生成
 *
 * 重要:
 * 1. 関数名は generateStaticParams(スペルミス不可)
 * 2. layout.tsx または page.tsx で定義
 * 3. 返すパラメータ名はルートフォルダ名と一致([lang] → lang)
 */
export async function generateStaticParams() {
  console.log(`🌍 Generating static params for ${i18nConfig.localesToPrerender.length} locales...`)

  return i18nConfig.localesToPrerender.map((locale) => ({
    lang: locale, // ⚠️ '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}
      // アラビア語の場合は RTL レイアウト
      dir={params.lang === 'ar' ? 'rtl' : 'ltr'}
    >
      <head>
        {/* グローバル meta タグをここに追加可能 */}
      </head>
      <body>
        {/* ナビバー、フッターなどのグローバルコンポーネント */}
        {children}
      </body>
    </html>
  )
}

/**
 * メタデータ生成(SEO)
 *
 * ページの <title>、<meta> などを生成
 */
export async function generateMetadata({ params }: { params: { lang: string } }) {
  return {
    // 言語関連 meta タグ
    alternates: {
      canonical: `https://example.com/${params.lang}`,
      languages: {
        'en': 'https://example.com/en',
        'zh-CN': 'https://example.com/zh-CN',
        'ja': 'https://example.com/ja',
      },
    },
    // Open Graph(SNS シェア用)
    openGraph: {
      locale: params.lang,
      alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
    },
  }
}

よくある落とし穴

⚠️ 落とし穴 1:パラメータ名の不一致

// ❌ 誤り:パラメータ名が locale だが、ルートフォルダは [lang]
export async function generateStaticParams() {
  return [{ locale: 'en' }]  // エラーになる
}

// ✅ 正しい:パラメータ名とフォルダ名を一致させる
export async function generateStaticParams() {
  return [{ lang: 'en' }]  // lang であること
}

⚠️ 落とし穴 2:動的 API の使用

// ❌ 誤り:静的生成ページで cookies を使用
export default async function Layout({ children }) {
  const locale = cookies().get('NEXT_LOCALE') // ビルド失敗の原因
  return <html lang={locale}>{children}</html>
}

// ✅ 正しい:ルートパラメータを使用
export default async function Layout({ children, params }) {
  return <html lang={params.lang}>{children}</html>
}

ステップ 5:翻訳ファイルの作成

翻訳ファイルの構造も重要です。推奨フォーマットは次のとおり。

// i18n/locales/zh-CN/common.json
{
  "nav": {
    "home": "首页",
    "about": "关于",
    "blog": "博客",
    "contact": "联系"
  },
  "footer": {
    "copyright": "© {{year}} 版权所有",
    "privacy": "隐私政策",
    "terms": "服务条款"
  },
  "actions": {
    "readMore": "阅读更多",
    "backToTop": "返回顶部",
    "share": "分享",
    "edit": "编辑"
  },
  "messages": {
    "loading": "加载中...",
    "error": "出错了",
    "success": "操作成功",
    "noResults": "没有找到结果"
  }
}
// i18n/locales/zh-CN/blog.json
{
  "title": "博客文章",
  "publishedAt": "发布于",
  "author": "作者",
  "tags": "标签",
  "relatedPosts": "相关文章",
  "readingTime": "阅读时间:{{minutes}} 分钟",
  "shareOn": "分享到 {{platform}}"
}

翻訳ファイルのベストプラクティス

  1. 階層構造:ネストオブジェクトで整理。すべての key をトップレベルに置かない。
  2. 変数プレースホルダー{{変数名}} 形式で統一処理。
  3. key 構造の統一:全言語で同じ key 構造を維持。
  4. コメント追加:複雑な翻訳には使用シーンをコメントで補足。

ステップ 6:ネストした動的ルートの処理

ブログや商品詳細がある場合、ネストした動的ルートが必要です。私が最もハマったポイントのひとつです。

// app/[lang]/blog/[slug]/page.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations, createTranslator } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'

// ヘルパー関数(実プロジェクトでは自前実装)
async function getBlogSlugs(): Promise<string[]> {
  // ファイルシステムまたは CMS から全 slug を取得
  return ['getting-started', 'advanced-tips', 'performance-guide']
}

async function getBlogPost(slug: string, locale: Locale) {
  // 特定言語のブログ記事を取得
  // ...
}

/**
 * 【重要】ネストルートの generateStaticParams
 *
 * 言語 × 記事 の全組み合わせを生成
 * 例:en/getting-started、zh-CN/getting-started、en/advanced-tips...
 */
export async function generateStaticParams() {
  const startTime = Date.now()
  console.log('📝 Generating blog post params...')

  // 全 slug を 1 回だけ取得
  const slugs = await getBlogSlugs()

  // flatMap で言語 × 記事の組み合わせを生成
  const params = i18nConfig.localesToPrerender.flatMap((locale) =>
    slugs.map((slug) => ({
      lang: locale,
      slug: slug,
    }))
  )

  const duration = Date.now() - startTime
  console.log(`✅ Generated ${params.length} blog post params in ${duration}ms`)

  return params
}

/**
 * ブログ記事ページ
 */
export default async function BlogPost({
  params,
}: {
  params: { lang: string; slug: string }
}) {
  // 翻訳と記事内容を並行読み込み
  const [translations, post] = await Promise.all([
    loadTranslations(params.lang as Locale, ['common', 'blog']),
    getBlogPost(params.slug, params.lang as Locale),
  ])

  const t = createTranslator(translations)

  return (
    <article className="prose">
      <h1>{post.title}</h1>
      <p className="text-gray-600">
        {t('blog.publishedAt')}: {new Date(post.date).toLocaleDateString(params.lang)}
      </p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

パフォーマンス最適化のポイント

言語ごとにデータを取得しようとする誤りに注意。

// ❌ 誤り:言語ごとにリクエスト(ビルドが遅い)
export async function generateStaticParams() {
  const results = []

  for (const locale of i18nConfig.localesToPrerender) {
    // 言語ごとに DB/CMS へリクエスト — 遅い!
    const slugs = await getBlogSlugs(locale)
    results.push(...slugs.map(slug => ({ lang: locale, slug })))
  }

  return results
}

正しくは 1 回だけデータを取得し、flatMap で組み合わせを生成します。

// ✅ 正しい:1 回のリクエストで高速生成
export async function generateStaticParams() {
  // データは 1 回だけ取得
  const slugs = await getBlogSlugs()

  // flatMap で言語 × 記事の組み合わせ
  return i18nConfig.localesToPrerender.flatMap((locale) =>
    slugs.map((slug) => ({ lang: locale, slug }))
  )
}

私のプロジェクトでは、この最適化でビルド時間が 18 分から 6 分に短縮されました。効果は明確です。

ステップ 7:Middleware による言語検出

Middleware はユーザーの言語設定を自動検出し、対応する言語版へリダイレクトします。

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

/**
 * Middleware
 *
 * 各リクエスト前に実行され:
 * 1. ユーザーの言語設定を検出
 * 2. 対応する言語パスへリダイレクト
 */
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // パスに言語プレフィックスが含まれるか確認
  const pathnameHasLocale = i18nConfig.locales.some(
    (locale) =>
      pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )

  // 既に言語プレフィックスがあればそのまま通過
  if (pathnameHasLocale) return

  // ユーザーの優先言語を取得
  const locale = getLocale(request) ?? i18nConfig.defaultLocale

  // 言語プレフィックス付きパスへリダイレクト
  request.nextUrl.pathname = `/${locale}${pathname}`
  return NextResponse.redirect(request.nextUrl)
}

/**
 * 言語検出
 *
 * 優先順位:
 * 1. Cookie に保存された言語設定
 * 2. Accept-Language ヘッダー
 * 3. null を返しデフォルト言語を使用
 */
function getLocale(request: NextRequest): string | null {
  // 優先度 1:Cookie を確認
  const localeCookie = request.cookies.get('NEXT_LOCALE')?.value
  if (localeCookie && i18nConfig.locales.includes(localeCookie as any)) {
    return localeCookie
  }

  // 優先度 2:Accept-Language ヘッダーを確認
  const acceptLanguage = request.headers.get('accept-language')
  if (acceptLanguage) {
    // Accept-Language 形式:zh-CN,zh;q=0.9,en;q=0.8
    const preferred = acceptLanguage.split(',')[0].split('-')[0]
    const match = i18nConfig.locales.find(locale =>
      locale.toLowerCase().startsWith(preferred.toLowerCase())
    )
    if (match) return match
  }

  // 一致なし
  return null
}

/**
 * Middleware 設定
 *
 * matcher で middleware を実行するパスを定義
 */
export const config = {
  // 以下を除く全パスにマッチ:
  // - /api で始まる API ルート
  // - /_next/static 静的ファイル
  // - /_next/image 画像
  // - /favicon.ico など静的リソース
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

Middleware の動作例

ユーザーが https://example.com/blog に直接アクセスした場合:

  1. Cookie に保存された言語設定を確認(例:前回中国語を選択)
  2. なければブラウザの Accept-Language ヘッダーを確認
  3. 検出結果に基づき https://example.com/zh-CN/blog または https://example.com/en/blog へリダイレクト

自動言語検出で UX が向上します。

ステップ 8:言語切り替えコンポーネント

ユーザーが手動で言語を切り替えられる UI です。

// components/LanguageSwitcher.tsx
'use client'

import { usePathname, useRouter } from 'next/navigation'
import { i18nConfig } from '@/i18n/config'
import type { Locale } from '@/i18n/config'

// 言語表示名マッピング
const localeNames: Record<Locale, string> = {
  'en': 'English',
  'zh-CN': '简体中文',
  'ja': '日本語',
}

export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
  const pathname = usePathname()
  const router = useRouter()

  const handleLocaleChange = (newLocale: Locale) => {
    // 言語設定を Cookie に保存
    document.cookie = `NEXT_LOCALE=${newLocale};path=/;max-age=31536000`

    // パス内の言語プレフィックスを置換
    // 例:/zh-CN/blog → /en/blog
    const newPathname = pathname.replace(`/${currentLocale}`, `/${newLocale}`)

    // 新しい言語版へ遷移
    router.push(newPathname)
  }

  return (
    <div className="relative">
      <select
        value={currentLocale}
        onChange={(e) => handleLocaleChange(e.target.value as Locale)}
        className="px-4 py-2 border rounded-lg"
      >
        {i18nConfig.locales.map((locale) => (
          <option key={locale} value={locale}>
            {localeNames[locale]}
          </option>
        ))}
      </select>
    </div>
  )
}

ナビバーでの使用例:

// components/Navigation.tsx
import { LanguageSwitcher } from './LanguageSwitcher'

export function Navigation({ lang }: { lang: string }) {
  return (
    <nav className="flex items-center justify-between p-4">
      <div className="flex gap-4">
        <a href={`/${lang}/`}>首页</a>
        <a href={`/${lang}/about`}>关于</a>
        <a href={`/${lang}/blog`}>博客</a>
      </div>
      <LanguageSwitcher currentLocale={lang} />
    </nav>
  )
}

パフォーマンス最適化:ビルド速度を上げる

基本機能が揃ったら、多言語サイトではビルド時間が課題になりがちです。実用的な最適化テクニックを紹介します。

最適化 1:選択的プリレンダリング

最も効果が高い方法です。10 言語サポートでもトラフィックの 2〜3 言語に集中しているなら、主要言語だけプリレンダリングします。

// i18n/config.ts
export const i18nConfig = {
  // サポートする全言語
  locales: ['en', 'zh-CN', 'ja', 'ko', 'de', 'fr', 'es', 'pt'],
  defaultLocale: 'en',

  // 【重要】主要言語のみプリレンダリング
  localesToPrerender: process.env.NODE_ENV === 'production'
    ? ['en', 'zh-CN']  // 本番:英語と中国語のみ
    : ['en'],          // 開発:デフォルト言語のみ(開発速度向上)
}

効果比較

設定ビルド時間説明
8 言語をプリレンダリング約 24 分全言語で静的ページを生成
2 言語をプリレンダリング約 6 分その他は初回アクセス時に生成
1 言語のみ約 3 分開発環境向け

ビルド時間を約 75% 削減できます。

最適化 2:インクリメンタル静的再生成(ISR)

重要度の低い言語やアクセスの少ないページは ISR でオンデマンド生成します。

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

// ISR を有効化(1 時間後に再検証)
export const revalidate = 3600

export async function generateStaticParams() {
  const slugs = await getBlogSlugs()

  // 主要言語の人気記事のみプリレンダリング
  const topSlugs = slugs.slice(0, 10)  // 上位 10 件のみ

  return i18nConfig.localesToPrerender.flatMap((locale) =>
    topSlugs.map((slug) => ({ lang: locale, slug }))
  )
}

// 【重要】未プリレンダリングページの動的生成を許可
export const dynamicParams = true

この設定により:

  1. ビルド時は 2 言語 × 10 記事 = 20 ページのみ生成
  2. 未プリレンダリングページはアクセス時にリアルタイム生成・キャッシュ
  3. キャッシュは 1 時間後に自動更新

最適化 3:データ取得の並列化

generateStaticParams で複数データが必要な場合は、必ず並列処理します。

// ❌ 誤り:直列取得(遅い)
export async function generateStaticParams() {
  const posts = await getBlogPosts()      // 2 秒待機
  const categories = await getCategories() // 1 秒待機
  // 合計 3 秒
}

// ✅ 正しい:並列取得(速い)
export async function generateStaticParams() {
  const [posts, categories] = await Promise.all([
    getBlogPosts(),      // 同時実行
    getCategories(),     // 同時実行
  ])
  // 合計 2 秒(最長の処理時間)
}

私のプロジェクトでは、データ取得時間が約 40% 短縮されました。

最適化 4:翻訳キャッシュ問題の解決

開発中に翻訳ファイルを更新してもページが変わらないのは、Next.js が import した JSON をキャッシュしているためです。

解決策:開発環境でキャッシュを無効化

// i18n/utils.ts
import fs from 'fs/promises'
import path from 'path'

const isDev = process.env.NODE_ENV === 'development'

export async function loadTranslations(
  locale: Locale,
  namespaces: Namespace[]
) {
  // 開発環境:毎回ファイルを再読み込み
  if (isDev) {
    const translations: Record<string, any> = {}

    for (const ns of namespaces) {
      const filePath = path.join(
        process.cwd(),
        'i18n',
        'locales',
        locale,
        `${ns}.json`
      )

      try {
        const content = await fs.readFile(filePath, 'utf-8')
        translations[ns] = JSON.parse(content)
      } catch (error) {
        console.warn(`Translation file not found: ${filePath}`)
        translations[ns] = {}
      }
    }

    return translations
  }

  // 本番環境:キャッシュを使用
  return loadTranslationsWithCache(locale, namespaces)
}

開発中は翻訳更新後、ページをリロードするだけで最新内容が反映されます。

よくある問題のトラブルシューティング

実開発で遭遇しやすい問題と、私の対処法をまとめました。

問題 1:ビルドエラー “generateStaticParams not found”

エラーメッセージ

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

確認手順

  1. layout.tsx または page.tsxgenerateStaticParams が定義されているか
  2. ✅ 関数名のスペルが正しいか(getStaticParamsgenerateParams ではない)
  3. export async function で正しくエクスポートされているか
  4. ✅ パラメータ名がルートフォルダ名と一致しているか
// ❌ 誤り
export async function getStaticParams() {  // 関数名が違う
  return [{ locale: 'en' }]  // パラメータ名も違う
}

// ✅ 正しい
export async function generateStaticParams() {
  return [{ lang: 'en' }]  // [lang] と一致させる
}

問題 2:Dynamic rendering detected

エラーメッセージ

Error: Route /[lang]/about couldn't be rendered statically
because it used `headers` or `cookies`.

原因:静的生成ページで headers()cookies()searchParams などの動的 API を使用している。

解決策

// ❌ 誤り:サーバーコンポーネントで cookies を使用
export default async function Page() {
  const locale = cookies().get('NEXT_LOCALE')  // 動的レンダリングをトリガー
  return <div>...</div>
}

// ✅ 方法 1:middleware で処理
// middleware.ts
export function middleware(request: NextRequest) {
  const locale = request.cookies.get('NEXT_LOCALE')
  // 処理ロジック...
}

// ✅ 方法 2:クライアントコンポーネントを使用
'use client'
export function LanguageSwitcher() {
  const [locale, setLocale] = useState(() => {
    // クライアント側で Cookie を読み取り
    return getCookie('NEXT_LOCALE')
  })
  // ...
}

問題 3:Translation file not found

エラーメッセージ

Error: Cannot find module './locales/en/common.json'

確認リスト

  1. ✅ ファイルパスが正しいか(大文字小文字に注意。Linux は区別する)
  2. ✅ JSON 構文が正しいか(オンラインツールで検証可能)
  3. tsconfig.json のパスエイリアス設定
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}
  1. ✅ ビルド時に翻訳ファイルが含まれているか
// next.config.js
module.exports = {
  // JSON ファイルを確実に含める
  webpack: (config) => {
    config.module.rules.push({
      test: /\.json$/,
      type: 'json',
    })
    return config
  },
}

問題 4:言語切り替え後にルートパラメータが失われる

現象/zh-CN/blog/my-post から英語に切り替えると /en/blog/my-post ではなく /en/ に飛ぶ。

原因:言語切り替えコンポーネントがルートパラメータを正しく保持していない。

解決策

// ❌ 誤り:パスをハードコード
<Link href="/about">About</Link>

// ✅ 方法 1:言語パラメータを手動で付与
<Link href={`/${params.lang}/about`}>About</Link>

// ✅ 方法 2:LocalizedLink コンポーネントでラップ
// components/LocalizedLink.tsx
'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

export function LocalizedLink({
  href,
  children,
  ...props
}: {
  href: string
  children: React.ReactNode
  [key: string]: any
}) {
  const pathname = usePathname()
  // 現在のパスから言語を抽出
  const locale = pathname.split('/')[1]

  // 言語プレフィックスを自動付与
  const localizedHref = `/${locale}${href}`

  return (
    <Link href={localizedHref} {...props}>
      {children}
    </Link>
  )
}

問題 5:SEO タグの欠落や誤設定

問題:多言語ページの hreflang、canonical 設定が不適切で、検索エンジンのインデックスに悪影響。

解決策:各ページの generateMetadata で正しく設定します。

// app/[lang]/blog/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: { lang: string; slug: string }
}) {
  const baseUrl = 'https://example.com'

  return {
    // ページタイトルと説明
    title: 'My Blog Post',
    description: 'This is a blog post',

    // Canonical URL
    alternates: {
      canonical: `${baseUrl}/${params.lang}/blog/${params.slug}`,
      // hreflang(他言語版を検索エンジンに通知)
      languages: {
        'en': `${baseUrl}/en/blog/${params.slug}`,
        'zh-CN': `${baseUrl}/zh-CN/blog/${params.slug}`,
        'ja': `${baseUrl}/ja/blog/${params.slug}`,
        'x-default': `${baseUrl}/en/blog/${params.slug}`, // デフォルト言語
      },
    },

    // Open Graph(SNS シェア用)
    openGraph: {
      title: 'My Blog Post',
      description: 'This is a blog post',
      url: `${baseUrl}/${params.lang}/blog/${params.slug}`,
      locale: params.lang,
      alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
    },
  }
}

ベストプラクティスまとめ

実践を通じて得た、すぐ実行できるチェックリストです。

プロジェクト初期化チェックリスト

開発開始前に以下を完了させましょう。

  • サポート言語とデフォルト言語を決定
  • app/[lang] ディレクトリ構造を作成
  • i18n/config.ts と翻訳ファイルディレクトリを設定
  • middleware.ts で言語検出を実装
  • ルートレイアウトに generateStaticParams を追加
  • next.config.js を設定(静的エクスポートなら output: 'export'

開発フェーズの推奨事項

  • 開発環境ではデフォルト言語のみプリレンダリング(localesToPrerender: ['en']
  • TypeScript で翻訳 key の型安全を確保
  • 機能モジュールごとに翻訳名前空間を分割(common、home、blog…)
  • 開発環境で翻訳キャッシュを無効化(fs.readFile でリアルタイム読み込み)
  • 欠落翻訳の警告ログを追加(問題の早期発見)

本番デプロイチェックリスト

  • 主要言語のみ選択的プリレンダリング(ビルド時間短縮)
  • ISR 戦略を設定(副次言語はオンデマンド生成、revalidate を設定)
  • 並列データ取得(Promise.all)を使用
  • hreflang と canonical タグを正しく設定
  • CDN キャッシュ戦略を設定(多言語パスを考慮)
  • 各言語版のアクセス数とビルド時間をモニタリング

next.config.js 完全設定例

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // 静的エクスポート(必要な場合)
  output: 'export',

  // 画像最適化設定
  images: {
    unoptimized: true, // 静的エクスポート時に必要
  },

  // 環境変数
  env: {
    BUILD_TIME: new Date().toISOString(),
  },

  // カスタムビルド ID(キャッシュ無効化用)
  generateBuildId: async () => {
    return `build-${Date.now()}`
  },

  // Webpack 設定
  webpack: (config, { isServer }) => {
    // JSON ファイルを正しく処理
    config.module.rules.push({
      test: /\.json$/,
      type: 'json',
    })

    return config
  },
}

module.exports = nextConfig

おすすめのツールとライブラリ

ゼロから実装したくない場合、以下のライブラリが便利です。

ツール/ライブラリ用途おすすめ度説明
next-intl完全な i18n ソリューション⭐⭐⭐⭐⭐公式推奨。機能が最も充実。App Router 対応
next-international軽量 i18n ライブラリ⭐⭐⭐⭐軽量で型安全
@formatjs/intl国際化フォーマット⭐⭐⭐⭐日付・数値・通貨フォーマット
typesafe-i18n型安全な翻訳⭐⭐⭐⭐型定義を自動生成
i18next老舗 i18n ライブラリ⭐⭐⭐高機能だが App Router への適応が必要

個人的には next-intl を推奨します。Next.js App Router 専用設計で、すぐ使えます。i18n の実装原理を深く理解したい、高度なカスタマイズが必要な場合は、本文のような手動実装も良い選択です。

まとめ

Next.js App Router の多言語静的生成ソリューションを一通り実装しました。

  1. コア機能

    • [lang] 動的ルートによる多言語構造
    • generateStaticParams による静的ページ生成
    • 名前空間分割の翻訳ファイルシステム
    • Middleware による自動言語検出とリダイレクト
  2. パフォーマンス最適化

    • 主要言語の選択的プリレンダリング(ビルド時間約 75% 削減)
    • ISR による副次言語のオンデマンド生成
    • 並列データ取得
    • 開発環境でのキャッシュ無効化
  3. 問題解決

    • generateStaticParams の設定ミス
    • 動的 API によるビルド失敗
    • 翻訳ファイルのキャッシュ問題
    • 言語切り替え時のルート消失
    • SEO タグ設定

重要ポイント

  • App Router の i18n は手動実装が必要。Pages Router の設定は使えない
  • generateStaticParams は layout または page で定義し、パラメータ名を一致させる
  • 静的生成ページでは cookies()headers() などの動的 API は使えない
  • 選択的プリレンダリングと ISR を適切に使い、ビルド時間の肥大化を防ぐ

Next.js App Router で多言語サイトを構築中の方が、少しでも罠を避けられることを願います。国際化自体は難しくありません。Next.js のビルド機構を理解し、そのルールに沿って設定することが大切です。

手動実装が面倒なら、next-intl を試してみてください。かなりの手間が省けます。

Next.js 多言語静的生成の完全設定フロー

ビルドエラー解決からビルド時間最適化、翻訳更新対応までの完全手順

⏱️ 目安時間: 3 時間

  1. 1

    ステップ1: ビルドエラー解決:generateStaticParams の設定

    問題:Page "/en/about" is missing generateStaticParams()

    解決:layout.tsx で generateStaticParams を設定

    ```tsx
    // app/[locale]/layout.tsx
    export async function generateStaticParams() {
    return [
    { locale: 'zh' },
    { locale: 'en' },
    ]
    }

    export default async function LocaleLayout({
    children,
    params: { locale }
    }) {
    // ...
    }
    ```

    ポイント:
    • generateStaticParams は layout または page で定義
    • パラメータ名は [locale] と一致させる
    • 全言語版を返す

    注意:静的生成ページでは cookies()、headers() などの動的 API は使えない
  2. 2

    ステップ2: ビルド時間の最適化

    問題:6 言語 × 50 ページ = 300 ページでビルドに 15 分。

    最適化方法:

    1. 選択的プリレンダリング(重要ページのみ):
    ```tsx
    export async function generateStaticParams() {
    // ホームと About のみプリレンダリング
    return [
    { locale: 'zh', slug: 'home' },
    { locale: 'zh', slug: 'about' },
    { locale: 'en', slug: 'home' },
    { locale: 'en', slug: 'about' },
    ]
    }
    ```

    2. ISR(インクリメンタル静的再生成)を使用:
    ```tsx
    export const revalidate = 3600 // 1 時間後に再生成
    ```

    3. 並列ビルド:
    ```tsx
    export async function generateStaticParams() {
    const locales = ['zh', 'en', 'ja', 'ko', 'fr', 'de']
    const pages = ['home', 'about', 'contact']

    return locales.flatMap(locale =>
    pages.map(slug => ({ locale, slug }))
    )
    }
    ```

    効果:15 分から 5 分以内に短縮
  3. 3

    ステップ3: 翻訳更新が反映されない問題の解決

    問題:翻訳ファイルを更新してもサイトに古い翻訳が表示される。

    原因:Next.js が import した JSON をキャッシュする。

    解決方法:

    1. 動的 import を使用:
    ```tsx
    const messages = await import(`../messages/${locale}.json`)
    ```

    2. キャッシュをクリア:
    ```bash
    rm -rf .next
    npm run build
    ```

    3. タイムスタンプを使用:
    ```tsx
    const messages = await import(
    `../messages/${locale}.json?v=${Date.now()}`
    )
    ```

    ポイント:
    • 開発時は動的 import を使用
    • 本番環境ではキャッシュをクリア
    • タイムスタンプでキャッシュ回避
  4. 4

    ステップ4: 動的 API との衝突を避ける

    問題:静的生成ページでは cookies()、headers() などの動的 API は使えない。

    解決方法:

    1. ページタイプを確認:
    ```tsx
    // ❌ 誤り:静的ページで動的 API を使用
    export default async function Page() {
    const cookies = await cookies() // エラー
    return <div>...</div>
    }

    // ✅ 正しい:動的ページで動的 API を使用
    export const dynamic = 'force-dynamic'
    export default async function Page() {
    const cookies = await cookies() // OK
    return <div>...</div>
    }
    ```

    2. 静的ページと動的ページを分離:
    ```tsx
    // 静的ページ:動的 API を使わない
    // 動的ページ:dynamic = 'force-dynamic' を設定
    ```

    ポイント:
    • 静的ページでは動的 API 不可
    • 動的 API が必要なページは force-dynamic を指定
    • 静的・動的ページを適切に分離

FAQ

App Router で多言語サイトをビルドするとエラーになるのはなぜ?
原因:App Router は Pages Router の i18n 設定をサポートしない。

Pages Router:
• next.config.js の i18n フィールドで設定
• 言語切り替えを自動処理
• 全言語版を自動生成

App Router:
• next.config.js の i18n 設定は無視される
• generateStaticParams で手動生成が必要
• パラメータ名は [locale] と一致させる

エラーメッセージ:
```
Error: Page "/en/about" is missing generateStaticParams()
so it cannot be used with output: "export".
```

解決方法:
```tsx
// app/[locale]/layout.tsx
export async function generateStaticParams() {
return [
{ locale: 'zh' },
{ locale: 'en' },
]
}
```

ポイント:generateStaticParams は layout または page で定義し、パラメータ名は [locale] と一致させる。
多言語サイトのビルド時間をどう最適化する?
問題:6 言語 × 50 ページ = 300 ページでビルドに 15 分。

最適化方法:

1. 選択的プリレンダリング(重要ページのみ):
```tsx
export async function generateStaticParams() {
// ホームのみプリレンダリング
return [
{ locale: 'zh', slug: 'home' },
{ locale: 'en', slug: 'home' },
]
}
```

2. ISR(インクリメンタル静的再生成):
```tsx
export const revalidate = 3600 // 1 時間後に再生成
```

3. 並列ビルド:
```tsx
export async function generateStaticParams() {
const locales = ['zh', 'en']
const pages = ['home', 'about']

return locales.flatMap(locale =>
pages.map(slug => ({ locale, slug }))
)
}
```

効果:15 分から 5 分以内に短縮

推奨:選択的プリレンダリングと ISR を適切に使い、ビルド時間の肥大化を防ぐ。
翻訳を更新しても反映されないのはなぜ?
原因:Next.js が import した JSON ファイルをキャッシュする。

問題:
• 翻訳ファイルを更新
• 再ビルド・再デプロイ
• サイトには古い翻訳のまま
• ブラウザキャッシュを消さないと新内容が見えない

解決方法:

1. 動的 import を使用:
```tsx
const messages = await import(`../messages/${locale}.json`)
```

2. キャッシュをクリア:
```bash
rm -rf .next
npm run build
```

3. タイムスタンプを使用:
```tsx
const messages = await import(
`../messages/${locale}.json?v=${Date.now()}`
)
```

ポイント:
• 開発時は動的 import を使用
• 本番環境ではキャッシュをクリア
• タイムスタンプでキャッシュ回避

推奨:動的 import でキャッシュ問題を回避。
静的生成ページで動的 API は使える?
使えません。静的生成ページでは cookies()、headers() などの動的 API は使用不可。

エラー例:
```tsx
// ❌ 誤り:静的ページで動的 API を使用
export default async function Page() {
const cookies = await cookies() // エラー
return <div>...</div>
}
```

解決方法:

1. 動的ページとしてマーク:
```tsx
export const dynamic = 'force-dynamic'
export default async function Page() {
const cookies = await cookies() // OK
return <div>...</div>
}
```

2. 静的ページと動的ページを分離:
```tsx
// 静的ページ:動的 API を使わない
// 動的ページ:dynamic = 'force-dynamic' を設定
```

ポイント:
• 静的ページでは動的 API 不可
• 動的 API が必要なページは force-dynamic を指定
• 静的・動的ページを適切に分離

注意:force-dynamic を指定したページは静的生成されず、リクエストごとに再レンダリングされる。
generateStaticParams はどう設定する?
設定場所:layout.tsx または page.tsx で定義

コード例:
```tsx
// app/[locale]/layout.tsx
export async function generateStaticParams() {
return [
{ locale: 'zh' },
{ locale: 'en' },
]
}

export default async function LocaleLayout({
children,
params: { locale }
}) {
// ...
}
```

動的ページ:
```tsx
// app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts()
const locales = ['zh', 'en']

return locales.flatMap(locale =>
posts.map(post => ({
locale,
slug: post.slug
}))
)
}
```

ポイント:
• パラメータ名は動的ルート([locale]、[slug])と一致
• すべての可能なパラメータ組み合わせを返す
• 静的生成ページでは動的 API を使わない

注意:パラメータ組み合わせが多い場合は、選択的プリレンダリングや ISR を検討。
多言語サイトのベストプラクティスは?
設定の推奨:

1. generateStaticParams で全言語版を生成
2. 動的 API を避ける(必要なら force-dynamic を指定)
3. 選択的プリレンダリングと ISR を適切に使用
4. ビルド時間の肥大化を防ぐ

翻訳管理:
• JSON ファイルで翻訳を管理
• ネスト構造に対応
• TypeScript で型安全を実現
• i18n Ally VSCode 拡張を活用

パフォーマンス最適化:
• 重要ページのみ選択的プリレンダリング
• ISR でビルド時間を最適化
• 全量プリレンダリングを避ける

ポイント:
• App Router のビルド機構を理解する
• generateStaticParams を適切に設定
• 動的 API との衝突を避ける
• ビルド時間を最適化

推奨:手動実装が面倒なら next-intl を試してみてください。かなりの手間が省けます。

6分で読めます · 公開日: 2025年12月25日 · 更新日: 2026年6月8日

関連記事

コメント

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