🫥

Next.js 15へのアップグレード:非同期params/searchParamsへの移行と対応方法

に公開

概要

Next.js 14から15へのメジャーバージョンアップを実施しました。特にApp Routerのdynamic route paramsとsearchParamsが非同期APIに変更されたことで、全てのページコンポーネントとAPI Routesで修正が必要になりました。

この記事では、実際のアップグレードプロセスで遭遇した破壊的変更と、その対応方法について解説します。

技術スタック

  • フレームワーク: Next.js 14.2.5 → 15.5.4
  • 言語: TypeScript
  • パッケージマネージャー: pnpm
  • 構成: Monorepo (Turborepo)
  • 主な使用機能: App Router, Dynamic Routes, API Routes, Server Components

破壊的変更の内容

変更前(Next.js 14)

// app/org/[orgSlug]/customers/page.tsx
interface OrgCustomersPageProps {
  params: {
    orgSlug: string
  }
}

export default async function OrgCustomersPage({ params }: OrgCustomersPageProps) {
  const { orgSlug } = params  // ✅ 同期的にアクセス可能

  // 組織情報を取得
  const organization = await prisma.organization.findUnique({
    where: { slug: orgSlug },
  })

  return <div>...</div>
}

変更後(Next.js 15)

// app/org/[orgSlug]/customers/page.tsx
interface OrgCustomersPageProps {
  params: Promise<{     // ⚠️ Promiseでラップされる
    orgSlug: string
  }>
}

export default async function OrgCustomersPage({ params }: OrgCustomersPageProps) {
  const { orgSlug } = await params  // ✅ awaitが必要

  // 組織情報を取得
  const organization = await prisma.organization.findUnique({
    where: { slug: orgSlug },
  })

  return <div>...</div>
}

主な変更点

  1. params の型が { orgSlug: string } から Promise<{ orgSlug: string }> に変更
  2. paramsへのアクセスに await キーワードが必須
  3. searchParams も同様に Promise になる

なぜこの変更が行われたのか

Next.jsチームは、Partial Prerendering(PPR)とStreamingをより効率的にサポートするため、この設計変更を行いました。

メリット

  1. パフォーマンスの向上

    • ルートパラメータの解決を遅延できる
    • 必要になるまでparamsの処理を待たない
  2. Streaming対応

    • コンポーネントの一部を先に送信できる
    • パラメータに依存しないUIを先行表示
  3. Partial Prerendering(PPR)

    • 静的部分と動的部分を分離しやすい
    • より細かい粒度でのレンダリング最適化

実際の移行作業

影響範囲の確認

まず、どのファイルが影響を受けるか確認しました。

# Dynamic Routeを使用しているページを確認
find apps/web/app \( -name "page.tsx" -o -name "route.ts" \) -exec grep -l "params:" {} \;

# または、より詳細な情報を見たい場合
find apps/web/app \( -name "page.tsx" -o -name "route.ts" \) | xargs grep -l "params:"

影響を受けたファイル

  • ✅ 8つのページコンポーネント
  • ✅ 2つのAPI Route
  • ✅ 1つのLayoutコンポーネント

11ファイルで修正が必要でした。

修正パターン1: ページコンポーネント(単一パラメータ)

最もシンプルなケースです。

// Before
interface OrgDashboardPageProps {
  params: {
    orgSlug: string
  }
}

export default async function OrgDashboardPage({ params }: OrgDashboardPageProps) {
  const { orgSlug } = params
  await requireAuth(orgSlug)
  // ...
}

// After
interface OrgDashboardPageProps {
  params: Promise<{
    orgSlug: string
  }>
}

export default async function OrgDashboardPage({ params }: OrgDashboardPageProps) {
  const { orgSlug } = await params  // ✅ awaitを追加
  await requireAuth(orgSlug)
  // ...
}

修正パターン2: ページコンポーネント(複数パラメータ)

ネストしたDynamic Routeの場合です。

// Before
interface OrgCustomerDetailPageProps {
  params: {
    orgSlug: string
    id: string
  }
}

export default async function OrgCustomerDetailPage({ params }: OrgCustomerDetailPageProps) {
  const { orgSlug, id } = params
  // ...
}

// After
interface OrgCustomerDetailPageProps {
  params: Promise<{
    orgSlug: string
    id: string
  }>
}

export default async function OrgCustomerDetailPage({ params }: OrgCustomerDetailPageProps) {
  const { orgSlug, id } = await params  // ✅ awaitを追加
  // ...
}

修正パターン3: API Route

API Routeも同様の修正が必要です。

// Before
export async function POST(
  request: NextRequest,
  { params }: { params: { orgSlug: string } }
) {
  const { orgSlug } = params
  const body = await request.json()
  // ...
}

// After
export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ orgSlug: string }> }
) {
  const { orgSlug } = await params  // ✅ awaitを追加
  const body = await request.json()
  // ...
}

修正パターン4: cookies() / headers() も非同期化

Next.js 15では、cookies()headers() も非同期APIになりました。

import { cookies } from 'next/headers'

// Before
export async function POST(request: NextRequest, { params }: ...) {
  const cookieStore = cookies()  // ❌ 同期呼び出し
  cookieStore.set('auth-token', token, { ... })
}

// After
export async function POST(request: NextRequest, { params }: ...) {
  const cookieStore = await cookies()  // ✅ awaitが必要
  cookieStore.set('auth-token', token, { ... })
}

重要: cookies()headers() は、Server ComponentとAPI Routeの両方で await が必要になります。

修正パターン5: Layoutコンポーネント

Layoutでもparamsを使う場合は同様に修正します。

// Before
interface OrgLayoutProps {
  children: React.ReactNode
  params: {
    orgSlug: string
  }
}

export default async function OrgLayout({ children, params }: OrgLayoutProps) {
  const { orgSlug } = params
  // 組織の存在確認
  const organization = await prisma.organization.findUnique({
    where: { slug: orgSlug },
  })
  // ...
}

// After
interface OrgLayoutProps {
  children: React.ReactNode
  params: Promise<{
    orgSlug: string
  }>
}

export default async function OrgLayout({ children, params }: OrgLayoutProps) {
  const { orgSlug } = await params  // ✅ awaitを追加
  const organization = await prisma.organization.findUnique({
    where: { slug: orgSlug },
  })
  // ...
}

TypeScriptエラーへの対応

エラー1: Type 'Promise<{ ... }>' is missing properties

Type 'Promise<{ orgSlug: string; }>' is missing the following properties
from type '{ orgSlug: string; }': orgSlug

原因: paramsの型定義が古いまま

解決: 型定義を Promise<{ ... }> に更新

// ❌ Before
params: { orgSlug: string }

// ✅ After
params: Promise<{ orgSlug: string }>

エラー2: Property 'orgSlug' does not exist on type 'Promise<...>'

Property 'orgSlug' does not exist on type 'Promise<{ orgSlug: string; }>'

原因: await なしでparamsにアクセスしている

解決: await を追加

// ❌ Before
const { orgSlug } = params

// ✅ After
const { orgSlug } = await params

エラー3: 'cookies' implicitly has type 'any'

Variable 'cookieStore' implicitly has type 'any'

原因: cookies() を非同期で呼んでいない

解決: await を追加

// ❌ Before
const cookieStore = cookies()

// ✅ After
const cookieStore = await cookies()

一括修正スクリプト

影響箇所が多い場合は、正規表現での一括置換が便利です。

# VSCodeの検索・置換機能を使用
# 検索: params: \{\s*([^}]+)\s*\}
# 置換: params: Promise<{ $1 }>

# sedを使った例(macOS)
find apps/web/app -name "*.tsx" -o -name "*.ts" | \
  xargs sed -i '' 's/params: {/params: Promise<{/g'

注意: 自動置換後は必ず手動で確認しましょう。await の追加は自動化できません。

ビルドとテストの実施

1. TypeScriptの型チェック

pnpm typecheck

すべてのTypeScriptエラーが解消されていることを確認します。

2. ローカルビルド

pnpm build

ビルドエラーがないことを確認します。

3. 動作確認

pnpm dev

実際にアプリケーションを起動して、各ページが正常に動作するか確認します。

確認項目:

  • ✅ Dynamic Routeのページが表示される
  • ✅ パラメータが正しく取得できている
  • ✅ API Routeが正常に動作する
  • ✅ Cookieの読み書きが動作する

移行時のベストプラクティス

1. 段階的な移行

一度にすべてを変更せず、段階的に進めます。

  1. 型定義の更新 → TypeScriptエラーを確認
  2. await の追加 → ビルドエラーを確認
  3. 動作確認 → 実際の動作をテスト

2. 共通ユーティリティ関数の活用

params取得を共通化することで、移行を簡単にできます。

// lib/params-utils.ts
export async function getOrgSlug(params: Promise<{ orgSlug: string }>) {
  const { orgSlug } = await params
  return orgSlug
}

// 使用例
export default async function OrgDashboardPage({ params }: OrgDashboardPageProps) {
  const orgSlug = await getOrgSlug(params)
  // ...
}

3. Lintルールの追加

非同期API呼び出しの漏れを防ぐため、ESLintルールを追加します。

// .eslintrc.json
{
  "rules": {
    "@typescript-eslint/no-floating-promises": "error",
    "@typescript-eslint/require-await": "warn"
  }
}

その他の注意点

searchParamsも非同期化

params だけでなく、searchParams も非同期になります。

// Before
interface PageProps {
  searchParams: {
    query?: string
    page?: string
    sort?: string
  }
}

export default async function SearchPage({ searchParams }: PageProps) {
  const { query, page = '1', sort = 'desc' } = searchParams

  const results = await prisma.post.findMany({
    where: query ? {
      title: { contains: query }
    } : undefined,
    skip: (parseInt(page) - 1) * 10,
    take: 10,
    orderBy: { createdAt: sort as 'asc' | 'desc' }
  })

  return <SearchResults results={results} />
}

// After
interface PageProps {
  searchParams: Promise<{
    query?: string
    page?: string
    sort?: string
  }>
}

export default async function SearchPage({ searchParams }: PageProps) {
  const { query, page = '1', sort = 'desc' } = await searchParams  // ✅ awaitが必要

  const results = await prisma.post.findMany({
    where: query ? {
      title: { contains: query }
    } : undefined,
    skip: (parseInt(page) - 1) * 10,
    take: 10,
    orderBy: { createdAt: sort as 'asc' | 'desc' }
  })

  return <SearchResults results={results} />
}

paramsとsearchParamsの組み合わせ

両方を使用する場合の例:

// After
interface BlogPostPageProps {
  params: Promise<{
    slug: string
  }>
  searchParams: Promise<{
    preview?: string
    token?: string
  }>
}

export default async function BlogPostPage({
  params,
  searchParams
}: BlogPostPageProps) {
  // Promise.allで並列に解決
  const [{ slug }, { preview, token }] = await Promise.all([
    params,
    searchParams
  ])

  // プレビューモードの判定
  const isPreview = preview === 'true' && token

  const post = await getPost(slug, isPreview ? token : undefined)

  return <PostContent post={post} />
}

Client Componentでは使用できない

paramssearchParamsServer Componentでのみ利用可能です。

Client Componentで使う場合は、親のServer ComponentでPropsとして渡す必要があります。

// ✅ Server Component
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  return <ClientComponent id={id} />
}

// ✅ Client Component
'use client'
export default function ClientComponent({ id }: { id: string }) {
  // idを使った処理
}

パフォーマンスへの影響

理論上のメリット

  • Streaming SSR: paramsに依存しない部分を先に送信できる
  • Partial Prerendering: 静的部分と動的部分を効率的に分離

実際の影響(我々のケース)

小〜中規模のアプリケーションでは、体感できるほどの変化はありませんでした

しかし、将来的にNext.jsがStreaming/PPRをさらに最適化すれば、この設計変更の恩恵を受けられるでしょう。

まとめ

Next.js 15への移行で最も影響が大きかったのは、paramsとsearchParamsの非同期化でした。

移行作業のポイント

  1. 型定義を Promise<{ ... }> に変更
  2. すべてのparams/searchParamsアクセスに await を追加
  3. cookies()headers()await が必要
  4. TypeScriptエラーを確認しながら段階的に修正
  5. ビルド・動作確認を忘れずに実施

移行の難易度

  • 小規模プロジェクト(〜10ファイル): 30分〜1時間
  • 中規模プロジェクト(〜50ファイル): 2〜3時間
  • 大規模プロジェクト(50ファイル以上): 半日〜1日

今回のプロジェクトでは、11ファイルの修正で約1時間でした。

メリット・デメリット

メリット

  • ⚡ Streaming SSRのパフォーマンス向上
  • 🎯 より細かい粒度でのレンダリング最適化
  • 🚀 将来的なPartial Prerenderingへの対応

デメリット

  • 💔 既存コードの破壊的変更
  • 📝 すべての該当箇所で修正が必要
  • 🤔 初学者には理解しにくい概念

今後の展望

Next.jsチームは、Streaming SSRとPartial Prerenderingをさらに強化していくと発表しています。今回の非同期API化は、その基盤となる重要な変更です。

破壊的変更ではありますが、将来のパフォーマンス向上を見据えた設計であることを理解した上で、早めの移行をおすすめします。


関連技術: Next.js, App Router, TypeScript, Server Components, Streaming SSR, Partial Prerendering

筆者: 91works開発チーム


この記事は、実際のプロダクション環境での移行経験をもとに執筆しています。Next.jsのバージョンやAPIは今後も変更される可能性があるため、公式ドキュメントも併せてご確認ください。

91works Tech Blog

Discussion