概要
Next.js 16 と next-intl(v4.8.2)で多言語化を実装する際の設定手順と、ハマりやすいポイントをまとめた。
まとめ
- Next.js 16 で
middleware.tsがproxy.tsに移行、params等の同期アクセスも完全廃止 cacheComponents+ Parallel Routes の動的セグメントで Suspense エラーが発生する- 解決策は
[locale]/layout.tsxを同期にし、<html>ごと<Suspense>内の非同期コンポーネントに委譲するパターン
Next.js 16 で変わったこと
middleware.ts → proxy.ts
middleware.ts が廃止され proxy.ts に置き換わった。proxy.ts は Node.js ランタイムのみで動作するため、Edge Runtime を前提にしていたコードは見直しが必要になる。
params の同期アクセスが完全廃止
Next.js 15 では deprecation warning だった params の同期アクセスが、16 ではエラーになる。searchParams、cookies()、headers() も同様。
// Next.js 16(await が必須)
export default async function Layout({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return <html lang={locale}>...</html>;
}既存プロジェクトには codemod が用意されている。
npx @next/codemod@canary upgrade latest
npx @next/codemod@latest next-async-request-api .Parallel Routes に default.tsx が必須
[locale] 配下で Parallel Routes を使っている場合、すべてのスロットに default.tsx が必要になった。
セットアップ手順
1. 翻訳ファイル
// messages/ja.json
{
"common": {
"signIn": "サインイン",
"signOut": "サインアウト"
},
"home": {
"title": "ようこそ",
"description": "{name}さんのダッシュボード"
}
}同じ構造で messages/en.json 等を用意する。
2. next.config.ts
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin()
const nextConfig = {
// ...
}
export default withNextIntl(nextConfig)3. リクエスト設定
import { getRequestConfig } from 'next-intl/server'
export default getRequestConfig(async ({ requestLocale }) => {
const locale = (await requestLocale) || 'ja'
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
}
})4. proxy.ts でのロケール検出
import { locales, defaultLocale } from './i18n/config'
export function proxy(request: Request) {
const url = new URL(request.url)
const pathname = url.pathname
const pathnameLocale = locales.find(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (pathnameLocale) return
const acceptLanguage = request.headers.get('accept-language')
const detectedLocale = negotiateLocale(acceptLanguage) || defaultLocale
return Response.redirect(new URL(`/${detectedLocale}${pathname}`, request.url))
}5. レイアウト
Parallel Routes を使わない(または cacheComponents を使わない)場合は、シンプルな async layout で問題ない。
import { setRequestLocale } from 'next-intl/server'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
export function generateStaticParams() {
return [{ locale: 'ja' }, { locale: 'en' }]
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const messages = await getMessages()
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}Parallel Routes + cacheComponents を使う場合は、このコードではエラーになる。 次のセクションで解決策を説明する。
6. コンポーネントでの使用
Server Component でも Client Component でも useTranslations が使える。
// Server Component
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = useTranslations('home');
return <h1>{t('title')}</h1>;
}// Client Component
'use client';
import { useTranslations } from 'next-intl';
export default function SignOutButton() {
const t = useTranslations('common');
return <button>{t('signOut')}</button>;
}cacheComponents + Parallel Routes での Suspense エラー
Next.js 16 では PPR と dynamicIO が cacheComponents に統合された。この機能を有効にした状態で、Parallel Routes 内に動的セグメント([documentId] など)があると、直接アクセス(ハードナビゲーション)時に以下のエラーが出る。
Uncached data was accessed outside of `<Suspense>`クライアントサイドナビゲーションでは発生しない。
原因
Parallel Routes には「1つのスロットが dynamic なら、同レベルの全スロットも dynamic になる」というルールがある。パラレルルート内の [documentId] のような動的セグメントがルートツリー全体を dynamic 扱いにし、[locale]/layout.tsx の await params が Suspense 外でのランタイムデータアクセスとしてエラーになる。
エラーのトレースは [locale]/layout.tsx を指すが、直接の原因は パラレルルート内の動的セグメントの存在 だ。
効かない対策
| 対策 | 効かない理由 |
|---|---|
loading.tsx の追加 | page をラップするだけで、parent layout の await params はカバーしない |
page を同期 + 子を <Suspense> で囲む | 問題は parent layout チェーンにある |
generateStaticParams の定義 | パラレルルートの動的セグメントが上書きする |
<html> を外に置き <body> 内だけ Suspense | await params が layout 関数内に残る |
解決策
[locale]/layout.tsx を同期コンポーネントにし、<html> タグを含む全コンテンツを <Suspense> 内の非同期コンポーネントに移動する。
// app/[locale]/layout.tsx
import { Suspense } from 'react';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { NextIntlClientProvider } from 'next-intl';
export function generateStaticParams() {
return [{ locale: 'ja' }, { locale: 'en' }];
}
async function LocaleContent({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const messages = await getMessages();
return (
<html lang={locale} suppressHydrationWarning>
<body>
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
</body>
</html>
);
}
export default function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
return (
<Suspense fallback={null}>
<LocaleContent params={params}>{children}</LocaleContent>
</Suspense>
);
}ポイントは <html> タグごと <Suspense> の中に入れる こと。これにより await params が Suspense 境界内に収まり、<html lang> も SSR で正しく出力される。
cacheComponents を使わない場合はこの問題は発生しないが、今後デフォルト有効になる可能性があるため、setRequestLocale() を呼ぶ習慣はつけておくとよい。
Next.js 15 からの移行チェックリスト
middleware.tsをproxy.tsに移行するparamsの同期アクセスをすべてawaitに変更するcookies()やheaders()を使ったロケール検出を非同期に変更する- Parallel Routes を使っている場合は
default.tsxを追加する cacheComponentsを有効にする場合はsetRequestLocale()を忘れずに呼ぶ- Parallel Routes +
cacheComponentsの場合は locale layout を Suspense パターンに変更する - Edge Runtime を前提にしたミドルウェアコードを見直す(
proxy.tsは Node.js ランタイムのみ)