App Routerはアップグレードではない — Reactの全く異なる書き方だ
App Routerのプロジェクトを初めて開いたとき、多くの人は単なる新しいフォルダ構造だと思う。それは間違いだ。App Routerは、データフェッチング、フォーム処理、そしてコンポーネントツリーの動作方法に関する考え方を根本から変える。
私はPages RouterからApp Routerへ50Kラインのコードベースをリファクタリングするのに約3週間かかった。最も高くついた教訓は「App Routerが複雑だ」ということではなく、手をつける前にテストカバレッジを確保しておくべきだということだ。コンポーネントツリーの構造が完全に変わると、テストがなければ何を壊しているかわからない。
始める前に理解すべき3つのコンセプト:
- Server Components — 完全にサーバー上で実行され、JavaScriptをクライアントに送信しない
- Server Actions — ミューテーション(フォーム送信、データ更新)のためのAPIルートを置き換える
- Partial Prerendering (PPR) — 1つのページでstaticとdynamicを組み合わせ、どちらかを選ぶ必要がない
Next.js 15のApp Routerインストール
npx create-next-app@latest my-app --typescript --tailwind --eslint --app
cd my-app
npm run dev
プロジェクト作成後のApp Routerディレクトリ構造:
app/
├── layout.tsx # Root layout(Server Component)
├── page.tsx # Home page(Server Component)
├── loading.tsx # Loading UI(自動Suspenseバウンダリー)
├── error.tsx # Error boundary(必ずClient Componentにすること)
├── globals.css
└── dashboard/
├── layout.tsx # Nested layout
└── page.tsx
初心者がよく混乱するポイント:デフォルトではapp/ディレクトリ内のすべてのコンポーネントはServer Componentだ。ファイルの先頭に"use client"を追加して初めてClient Componentになる。
詳細設定
Server Components — useEffectなしで直接データをフェッチ
おなじみの`useEffect + useState + loading state`というループの代わりに、Server Componentでは直接こう書ける:
// app/posts/page.tsx — Server Component(デフォルト)
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // 1時間キャッシュ、自動revalidate
})
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
気づいている人が少ないボーナス:Server ComponentはJavaScriptをクライアントに送信しない。date-fnsやmarkedのような重いライブラリをレンダリングだけに使う場合、サーバーサイドで実行されるだけで、クライアントのバンドルサイズは1バイトも増えない。
キャッシュ戦略のTips:
cache: 'force-cache'— 変化の少ないstaticデータ(デフォルト)next: { revalidate: N }— ISR、N秒後に自動更新cache: 'no-store'— dynamicデータ、毎リクエスト常に最新
しかし、Server Componentをいつ使わないべきかを知ることの方がより重要だ。useState/useEffect、イベントハンドラー、ブラウザAPI、またはReact contextを使うサードパーティライブラリがある場合はClient Componentが必要だ:
// app/components/LikeButton.tsx — Client Component
"use client"
import { useState } from 'react'
export function LikeButton({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount)
return (
<button onClick={() => setCount(c => c + 1)}>
❤️ {count}
</button>
)
}
Server Actions — APIルートなしでフォーム送信
Server Actionsは、App Routerに移行して最も気に入ったものだ。以前は各フォームに専用のAPIルートが必要だった — バリデーションはそこで、キャッシュのrevalidateはここで、リダイレクトはあちらで。今はすべてを1か所にまとめられる:
// app/actions/posts.ts
"use server"
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
if (!title?.trim()) {
return { error: 'タイトルは空にできません' }
}
// データベースを直接呼び出す — 完全にサーバー上で実行
await db.post.create({ data: { title, content } })
revalidatePath('/posts') // /postsページのキャッシュを削除
redirect('/posts')
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}
useActionStateと組み合わせてクライアント側でエラーを表示する:
// app/posts/new/CreatePostForm.tsx
"use client"
import { useActionState } from 'react'
import { createPost } from '../actions/posts'
export function CreatePostForm() {
const [state, action, isPending] = useActionState(createPost, null)
return (
<form action={action}>
{state?.error && (
<p className="text-red-500">{state.error}</p>
)}
<input name="title" placeholder="タイトル" required />
<textarea name="content" placeholder="本文" />
<button type="submit" disabled={isPending}>
{isPending ? '作成中...' : '投稿を作成'}
</button>
</form>
)
}
Partial Prerendering — staticとdynamicのトレードオフを解消
以前は選択を迫られた:このページをstatic(速いが、データがリアルタイムでない)にするか、dynamic(最新データだが、TTFBが高くなる)にするか。PPRはそのトレードオフをなくす。ページのstatic shellはすぐに表示される — TTFBはstaticページと同じだ。dynamicな部分はSuspenseを通じて後からストリームされる。
configでPPRを有効にする:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
}
export default nextConfig
ページで使用する — staticな部分はすぐにレンダリング、dynamicな部分は後からストリーム:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { StaticHeader } from './StaticHeader' // すぐにレンダリング — 待機不要
import { UserStats } from './UserStats' // 後からストリーム
import { RecentActivity } from './RecentActivity' // 後からストリーム
export default function DashboardPage() {
return (
<div>
{/* Static shell — すぐに表示、TTFB = staticページ */}
<StaticHeader />
{/* Dynamic — staticが表示された後にストリーム */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
)
}
結果:ユーザーはヘッダーとレイアウトをすぐに見ることができる。StatsとActivityは後から順番にストリームされる — 白い画面はなく、すべてのデータがロードされるまで待つ必要もない。
パフォーマンスの確認とモニタリング
npm run buildの出力を読む
npm run build
Next.jsは各ルートをシンボルで一覧表示する:
○— Static: ビルド時にレンダリング、最速●— ISR: staticだが定期的にrevalidateƒ— Dynamic: リクエストごとにレンダリング
目標は、できるだけ多くのルートを○または●に保つことだ。不必要にƒになるルートは見直しが必要だ — 通常、コンポーネント内でcookies()またはheaders()を誤って呼び出していることが原因だ。
キャッシュとルート動作のデバッグ
# development環境でキャッシュのhit/missの詳細ログを確認する
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev
# 各ルートのバンドルサイズを確認する
npx @next/bundle-analyzer
本番環境デプロイ前のチェックリスト
- staticページで
generateStaticParams()を使用して正しくpre-renderされているか? - staticコンポーネント内に
cookies()/headers()が誤って使われていないか(自動的にdynamicに変わる) - データフェッチが遅いルートに
loading.tsxが存在するか - 純粋な
<img>の代わりにNext.jsの<Image>を使用しているか — 自動的に最適化とlazy loadが行われる - Server Actionsに明確なエラーハンドリングがあるか — throwではなくエラーオブジェクトをreturnする
- ウォーターフォールリクエストを避けるためにSuspenseバウンダリーが適切な場所に配置されているか
本番環境でApp Routerを数ヶ月運用して、最大のメリットは速度ではなくコードベースがはるかにクリーンになったことだとわかった。getServerSidePropsとgetStaticPropsが混在することもなくなった。フォームを処理するためだけにAPIルートを作成する必要もない。データフェッチは本来あるべき場所に収まっている。Pages Routerを使っていてどこから始めればいいかわからない場合は、まずApp Routerで小さな機能を1つ書いてみよう。Next.jsは両方を並行して動かせるので、すぐに全部移行する必要はない。
