Next.js 16 と next-intl(v4.8.2)で多言語化を実装する際の設定手順と、ハマりやすいポイントをまとめた。
middleware.ts が proxy.ts に移行、params 等の同期アクセスも完全廃止cacheComponents + Parallel Routes の動的セグメントで Suspense エラーが発生する[locale]/layout.tsx を同期にし、<html> ごと <Suspense> 内の非同期コンポーネントに委譲するパターンmiddleware.ts が廃止され proxy.ts に置き換わった。proxy.ts は Node.js ランタイムのみで動作するため、Edge Runtime を前提にしていたコードは見直しが必要になる。
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 .[locale] 配下で Parallel Routes を使っている場合、すべてのスロットに default.tsx が必要になった。
// messages/ja.json
{
"common": {
"signIn": "サインイン",
"signOut": "サインアウト"
},
"home": {
"title": "ようこそ",
"description": "{name}さんのダッシュボード"
}
}同じ構造で messages/en.json 等を用意する。
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin()
const nextConfig = {
// ...
}
export default withNextIntl(nextConfig)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,
}
})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))
}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 を使う場合は、このコードではエラーになる。 次のセクションで解決策を説明する。
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>;
}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() を呼ぶ習慣はつけておくとよい。
middleware.ts を proxy.ts に移行するparams の同期アクセスをすべて await に変更するcookies() や headers() を使ったロケール検出を非同期に変更するdefault.tsx を追加するcacheComponents を有効にする場合は setRequestLocale() を忘れずに呼ぶcacheComponents の場合は locale layout を Suspense パターンに変更するproxy.ts は Node.js ランタイムのみ)