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.js で i18n を設定したのに、何のこと?」と。後からわかったのですが、App Router と Pages Router では国際化のやり方がまったく違い、以前の設定は通用しないということでした。
シーン 2:ビルド時間が長すぎる
別のプロジェクトでは 6 言語 × 約 50 ページをサポートしていました。1 回のビルド結果はこうでした。
✓ Generating static pages (152/152) - 15m 32s
15 分です。小さな修正のたびにこれだけ待つ開発体験は、正直つらいものでした。「本番 CI/CD だと、いつまで待たされるんだろう」と思いました。
シーン 3:翻訳更新が反映されない
これが一番イライラしたケースです。zh-CN.json を更新して、再ビルド・再デプロイしても、サイトには古い翻訳のまま。ブラウザキャッシュを消さないと新しい内容が見えない。本番環境ではユーザーに期限切れの内容を見せ続けることになりかねません。
問題の根本原因は?
かなり調べて、ようやく本質が見えてきました。
-
App Router は Pages Router の i18n 設定をサポートしない — 最大の落とし穴です。
next.config.jsのi18nフィールドは、App Router では無視されます。 -
静的エクスポートと動的レンダリングの衝突 —
output: 'export'を設定すると、Next.js はすべてのページをビルド時に確定させる必要があります。cookies()やheaders()などの動的 API を使うとエラーになります。 -
翻訳ファイルのキャッシュ — Next.js は import した JSON をキャッシュします。開発中に翻訳を更新してもキャッシュが残り、最新内容が表示されないことがあります。
同じような問題にぶつかった方のために、Next.js App Router で多言語静的生成を正しく実装し、これらの罠を避ける方法を順を追って説明します。
App Router の i18n 新パラダイムを理解する
コードを書く前に、App Router の国際化の考え方を押さえておきましょう。Pages Router とは本当に違います。
Pages Router vs App Router:まったく別の 2 つのアプローチ
比較表で違いを確認してください。
| 特性 | Pages Router | App Router |
|---|---|---|
| 設定方法 | next.config.js の i18n フィールド | 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-i18next の serverSideTranslations を使いました。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 # 言語検出とリダイレクト
この設計の理由
[lang]フォルダ:動的ルートの中核。URL の言語パラメータがページコンポーネントに渡されます。- 名前空間ごとの翻訳分割:1 ファイルが巨大化するのを防ぎ、ページ機能ごとに分割してオンデマンド読み込み。
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]
ポイント解説
as const:TypeScript の記法。型を広いstring[]ではなく正確なリテラル型に固定します。localesToPrerender:10 言語サポートでも 2 言語だけプリレンダリングすれば、ビルド時間を約 80% 削減できます。残りは ISR やオンデマンド生成で対応。- 名前空間:翻訳 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
}
}
このユーティリティの強み
- キャッシュ:初回読み込み後はキャッシュし、ファイルの再読み込みを回避。
- エラー処理:翻訳ファイルがなくてもクラッシュせず、警告して空オブジェクトを返す。
- 変数置換:翻訳内の
{{変数名}}プレースホルダーに対応。 - 型フレンドリー: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}}"
}
翻訳ファイルのベストプラクティス
- 階層構造:ネストオブジェクトで整理。すべての key をトップレベルに置かない。
- 変数プレースホルダー:
{{変数名}}形式で統一処理。 - key 構造の統一:全言語で同じ key 構造を維持。
- コメント追加:複雑な翻訳には使用シーンをコメントで補足。
ステップ 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 に直接アクセスした場合:
- Cookie に保存された言語設定を確認(例:前回中国語を選択)
- なければブラウザの
Accept-Languageヘッダーを確認 - 検出結果に基づき
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
この設定により:
- ビルド時は 2 言語 × 10 記事 = 20 ページのみ生成
- 未プリレンダリングページはアクセス時にリアルタイム生成・キャッシュ
- キャッシュは 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"`.
確認手順
- ✅
layout.tsxまたはpage.tsxにgenerateStaticParamsが定義されているか - ✅ 関数名のスペルが正しいか(
getStaticParams、generateParamsではない) - ✅
export async functionで正しくエクスポートされているか - ✅ パラメータ名がルートフォルダ名と一致しているか
// ❌ 誤り
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'
確認リスト
- ✅ ファイルパスが正しいか(大文字小文字に注意。Linux は区別する)
- ✅ JSON 構文が正しいか(オンラインツールで検証可能)
- ✅
tsconfig.jsonのパスエイリアス設定
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}
- ✅ ビルド時に翻訳ファイルが含まれているか
// 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 の多言語静的生成ソリューションを一通り実装しました。
-
コア機能
[lang]動的ルートによる多言語構造generateStaticParamsによる静的ページ生成- 名前空間分割の翻訳ファイルシステム
- Middleware による自動言語検出とリダイレクト
-
パフォーマンス最適化
- 主要言語の選択的プリレンダリング(ビルド時間約 75% 削減)
- ISR による副次言語のオンデマンド生成
- 並列データ取得
- 開発環境でのキャッシュ無効化
-
問題解決
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: ビルドエラー解決: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: ビルド時間の最適化
問題: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: 翻訳更新が反映されない問題の解決
問題:翻訳ファイルを更新してもサイトに古い翻訳が表示される。
原因: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: 動的 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 で多言語サイトをビルドするとエラーになるのはなぜ?
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] と一致させる。
多言語サイトのビルド時間をどう最適化する?
最適化方法:
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 を適切に使い、ビルド時間の肥大化を防ぐ。
翻訳を更新しても反映されないのはなぜ?
問題:
• 翻訳ファイルを更新
• 再ビルド・再デプロイ
• サイトには古い翻訳のまま
• ブラウザキャッシュを消さないと新内容が見えない
解決方法:
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 は使える?
エラー例:
```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 はどう設定する?
コード例:
```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日
Next.js 完全ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Next.js 多言語 SEO 最適化完全ガイド:検索エンジンに各言語を正しくインデックスさせる
多言語サイトの60%以上が SEO 設定に誤りがあります。hreflang タグの設定、多言語 Sitemap の生成、URL 戦略の選択など、よくある落とし穴を避け、各言語版が正しく検索順位を獲得するための核心技術を解説します。
第 38 / 47 記事
次の記事
Next.js ダークモード実装:next-themes 完全ガイド
ちらつき問題から完璧な解決策まで、next-themes で Next.js ダークモードをゼロちらつきで実装する手順を解説。完全なコード、仕組みの解説、よくあるトラブルシューティング付き。
第 40 / 47 記事
関連記事
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js Middleware 実践ガイド:パスマッチ、Edge Runtime 制限とよくある落とし穴
コメント
GitHubアカウントでログインしてコメントできます