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>
}
主な変更点:
-
paramsの型が{ orgSlug: string }からPromise<{ orgSlug: string }>に変更 - paramsへのアクセスに
awaitキーワードが必須 -
searchParamsも同様にPromiseになる
なぜこの変更が行われたのか
Next.jsチームは、Partial Prerendering(PPR)とStreamingをより効率的にサポートするため、この設計変更を行いました。
メリット
-
パフォーマンスの向上
- ルートパラメータの解決を遅延できる
- 必要になるまでparamsの処理を待たない
-
Streaming対応
- コンポーネントの一部を先に送信できる
- パラメータに依存しないUIを先行表示
-
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. 段階的な移行
一度にすべてを変更せず、段階的に進めます。
- 型定義の更新 → TypeScriptエラーを確認
-
awaitの追加 → ビルドエラーを確認 - 動作確認 → 実際の動作をテスト
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では使用できない
params と searchParams はServer 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の非同期化でした。
移行作業のポイント
- 型定義を
Promise<{ ... }>に変更 - すべてのparams/searchParamsアクセスに
awaitを追加 cookies()とheaders()もawaitが必要- TypeScriptエラーを確認しながら段階的に修正
- ビルド・動作確認を忘れずに実施
移行の難易度
- 小規模プロジェクト(〜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は今後も変更される可能性があるため、公式ドキュメントも併せてご確認ください。
Discussion