# Next.js 16 + next-intl で多言語化対応するための設定と注意点

## 概要

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()` も同様。

```tsx
// Next.js 16（await が必須）
export default async function Layout({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  return <html lang={locale}>...</html>;
}
```

既存プロジェクトには codemod が用意されている。

```bash
npx @next/codemod@canary upgrade latest
npx @next/codemod@latest next-async-request-api .
```

### Parallel Routes に default.tsx が必須

`[locale]` 配下で Parallel Routes を使っている場合、すべてのスロットに `default.tsx` が必要になった。

## セットアップ手順

### 1. 翻訳ファイル

```json
// messages/ja.json
{
  "common": {
    "signIn": "サインイン",
    "signOut": "サインアウト"
  },
  "home": {
    "title": "ようこそ",
    "description": "{name}さんのダッシュボード"
  }
}
```

同じ構造で `messages/en.json` 等を用意する。

### 2. next.config.ts

```ts:next.config.ts
import createNextIntlPlugin from 'next-intl/plugin'

const withNextIntl = createNextIntlPlugin()

const nextConfig = {
  // ...
}

export default withNextIntl(nextConfig)
```

### 3. リクエスト設定

```ts:i18n/request.ts
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 でのロケール検出

```ts: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 で問題ない。

```tsx:app/[locale]/layout.tsx
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` が使える。

```tsx
// 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>;
}
```

```tsx
// 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]` など）があると、直接アクセス（ハードナビゲーション）時に以下のエラーが出る。

```text
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>` 内の非同期コンポーネントに移動する。

```tsx
// 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 からの移行チェックリスト

1. `middleware.ts` を `proxy.ts` に移行する
2. `params` の同期アクセスをすべて `await` に変更する
3. `cookies()` や `headers()` を使ったロケール検出を非同期に変更する
4. Parallel Routes を使っている場合は `default.tsx` を追加する
5. `cacheComponents` を有効にする場合は `setRequestLocale()` を忘れずに呼ぶ
6. Parallel Routes + `cacheComponents` の場合は locale layout を Suspense パターンに変更する
7. Edge Runtime を前提にしたミドルウェアコードを見直す（`proxy.ts` は Node.js ランタイムのみ）
