Next.js App Router 実践ガイド
Next.js 14のApp Routerは、Reactのサーバーコンポーネントモデルを本格的に活用できる革命的なアーキテクチャです。本記事では、基礎的な使い方を超えて、実務で差がつく高度な機能を網羅的に解説します。
App Routerの本質を理解する
App Routerの核心はサーバーコンポーネントファーストという設計思想です。Pages Routerとの最大の違いは、コンポーネントがデフォルトでサーバー上でレンダリングされる点にあります。
app/
├── layout.tsx ← 必ずサーバーコンポーネント
├── page.tsx ← デフォルトはサーバーコンポーネント
├── loading.tsx ← Suspenseのフォールバック
├── error.tsx ← エラーバウンダリ(クライアント)
├── not-found.tsx ← 404ハンドラ
└── [slug]/
└── page.tsx ← 動的ルート
サーバーコンポーネント vs クライアントコンポーネント
判断基準を明確にしておくことが重要です:
| 機能 | サーバー | クライアント |
|---|---|---|
| データフェッチ(DB直接) | ✅ | ❌ |
| useState / useEffect | ❌ | ✅ |
| ブラウザAPI(window等) | ❌ | ✅ |
| イベントハンドラ | ❌ | ✅ |
| サードパーティUIライブラリ | 多くが❌ | ✅ |
// app/products/page.tsx — サーバーコンポーネント
// データフェッチをコンポーネント内で直接実行
async function ProductsPage() {
// DBや外部APIを直接呼べる(APIルート不要)
const products = await db.product.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
})
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
Server Actions — フォーム処理の革命
Server Actionsは、フォーム送信やデータ変更をサーバー側で安全に処理するための仕組みです。APIルートを書かずに、サーバー側のロジックを直接呼び出せます。
基本的なServer Action
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
})
export async function createPost(formData: FormData) {
const validated = createPostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
await db.post.create({ data: validated.data })
// キャッシュを無効化してUIを更新
revalidatePath('/posts')
redirect('/posts')
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" type="text" placeholder="タイトル" required />
<textarea name="content" placeholder="本文" required />
<button type="submit">投稿</button>
</form>
)
}
useActionStateでフォーム状態管理(React 19+)
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions'
type State = {
error?: { title?: string[]; content?: string[] }
message?: string
}
export function PostForm() {
const [state, dispatch, isPending] = useActionState<State, FormData>(
createPost,
{}
)
return (
<form action={dispatch}>
<input name="title" type="text" />
{state.error?.title && (
<p className="error">{state.error.title[0]}</p>
)}
<textarea name="content" />
{state.error?.content && (
<p className="error">{state.error.content[0]}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '送信中...' : '投稿'}
</button>
</form>
)
}
Streaming と Suspense — ユーザー体験を向上させる
StreamingはHTMLを段階的にクライアントへ送信する技術です。重いデータフェッチがあっても、準備できた部分から表示できます。
基本的なStreaming実装
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart } from './RevenueChart'
import { LatestOrders } from './LatestOrders'
import { StatCards } from './StatCards'
export default async function DashboardPage() {
return (
<div className="grid">
{/* 即座に表示 */}
<StatCards />
{/* 重いグラフはSuspenseで遅延 */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* 最新注文も独立してStream */}
<Suspense fallback={<OrdersSkeleton />}>
<LatestOrders />
</Suspense>
</div>
)
}
// app/dashboard/RevenueChart.tsx
// このコンポーネントのデータ待ちは他のUIをブロックしない
async function RevenueChart() {
// 重い計算・遅いクエリ
const revenue = await fetchRevenueData() // 3秒かかる
return <Chart data={revenue} />
}
loading.tsxによるルートレベルのSuspense
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded" />
))}
</div>
</div>
)
}
キャッシュ戦略 — パフォーマンスの要
Next.js App Routerには4層のキャッシュが存在します:
1. Request Memoization(リクエスト重複排除)
同一リクエスト内で同じURLへのfetchを自動重複排除します:
// 異なるコンポーネントから同じURLにfetchしても
// 実際のHTTPリクエストは1回だけ
async function UserAvatar({ userId }: { userId: string }) {
const user = await fetch(`/api/users/${userId}`).then(r => r.json())
return <img src={user.avatarUrl} />
}
async function UserName({ userId }: { userId: string }) {
const user = await fetch(`/api/users/${userId}`).then(r => r.json())
return <span>{user.name}</span>
}
2. Data Cache(データキャッシュ)
// デフォルト: 永続キャッシュ(force-cache)
const data = await fetch('https://api.example.com/data')
// キャッシュなし(SSR相当)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
// 時間ベースの再検証(ISR相当)
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // 1時間
})
// タグベースの再検証
const data = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
3. Full Route Cache(静的生成)
// app/blog/[slug]/page.tsx
// ビルド時に全スラッグを生成
export async function generateStaticParams() {
const posts = await db.post.findMany({ select: { slug: true } })
return posts.map(({ slug }) => ({ slug }))
}
// 静的生成 + ISR(1時間ごとに再検証)
export const revalidate = 3600
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) notFound()
return <Article post={post} />
}
4. キャッシュの手動無効化
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
// 特定パスのキャッシュを削除
export async function updatePost(id: string, data: PostData) {
await db.post.update({ where: { id }, data })
revalidatePath(`/blog/${data.slug}`)
revalidatePath('/blog') // 一覧ページも
}
// タグベースで関連する全キャッシュを削除
export async function deletePost(id: string) {
await db.post.delete({ where: { id } })
revalidateTag('posts')
}
Route Handlers — 型安全なAPIエンドポイント
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const updateUserSchema = z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
})
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({ where: { id: params.id } })
if (!user) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(user)
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const body = await request.json()
const validated = updateUserSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: validated.error.flatten() },
{ status: 400 }
)
}
const user = await db.user.update({
where: { id: params.id },
data: validated.data,
})
return NextResponse.json(user)
}
Middleware — リクエスト処理の高速化
Middlewareはエッジで実行され、認証・リダイレクト・ロケール処理に最適です:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from '@/lib/auth'
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 保護されたルート
if (pathname.startsWith('/dashboard')) {
const token = request.cookies.get('session')?.value
if (!token || !(await verifyToken(token))) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
// ロケールリダイレクト
if (pathname === '/') {
const acceptLanguage = request.headers.get('accept-language') ?? ''
if (acceptLanguage.startsWith('ja')) {
return NextResponse.redirect(new URL('/ja', request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/'],
}
PPR(Partial Prerendering)— Next.js 15の未来
PPRは静的部分と動的部分を同一ページで組み合わせる実験的機能です:
// next.config.ts
const nextConfig = {
experimental: {
ppr: 'incremental', // 段階的導入
},
}
// app/product/[id]/page.tsx
export const experimental_ppr = true
export default async function ProductPage({
params,
}: {
params: { id: string }
}) {
return (
<>
{/* 静的にプリレンダリング */}
<ProductDescription id={params.id} />
{/* 動的データはSuspenseで遅延 */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice id={params.id} />
</Suspense>
<Suspense fallback={<InventorySkeleton />}>
<LiveInventory id={params.id} />
</Suspense>
</>
)
}
実務でよくあるパターン
楽観的UI更新(Optimistic Update)
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions'
export function LikeButton({ postId, initialLiked, initialCount }: Props) {
const [optimisticLiked, setOptimistic] = useOptimistic(initialLiked)
async function handleClick() {
setOptimistic(!optimisticLiked) // 即座にUI更新
await toggleLike(postId) // 実際のサーバー処理
}
return (
<button onClick={handleClick}>
{optimisticLiked ? '❤️' : '🤍'} {initialCount}
</button>
)
}
Parallel Routes — 独立したローディング状態
app/
└── dashboard/
├── layout.tsx
├── @analytics/
│ ├── page.tsx
│ └── loading.tsx
└── @revenue/
├── page.tsx
└── loading.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
revenue,
}: {
children: React.ReactNode
analytics: React.ReactNode
revenue: React.ReactNode
}) {
return (
<div className="grid grid-cols-2">
{analytics}
{revenue}
</div>
)
}
パフォーマンス最適化のポイント
use clientを最小化: クライアントコンポーネントの境界を葉ノードに押し込むdynamic()で重いコンポーネントを遅延読み込み: チャートライブラリ等next/imageのpriority: Above-the-fold画像にはpriority設定unstable_cache: Server ComponentでのDB結果キャッシュ
import { unstable_cache } from 'next/cache'
const getCachedPosts = unstable_cache(
async (category: string) => {
return db.post.findMany({ where: { category } })
},
['posts-by-category'],
{ revalidate: 3600, tags: ['posts'] }
)
まとめ
App Routerは学習コストが高いですが、適切に使いこなせばパフォーマンスとDXの両面で大きな恩恵を受けられます。特に重要な順に習得するなら:
- サーバー/クライアントコンポーネントの使い分け
- Server Actions でフォーム処理を簡潔に
- Suspense + Streaming でUXを向上
- キャッシュ戦略でパフォーマンスを最大化
UI改善に役立つツールとして、DevToolBoxのカラーコントラストチェッカー(WCAG準拠確認)やCSS単位変換ツールも活用してみてください。
スキルアップ・キャリアアップのおすすめリソース
Next.js App Routerのスキルを活かして、さらなるキャリアアップを目指したい方へ。
転職・キャリアアップ
- レバテックキャリア — ITエンジニア専門の転職エージェント。Next.js案件は国内でも急増中。フルスタックエンジニアとしての市場価値を高めやすい。無料相談可能。
- Findy — GitHubスキル偏差値でNext.jsの実力をアピール。スカウト型でリモート・高単価の求人が多い。
オンライン学習
- Udemy — Next.js App RouterやServer Actionsに特化した最新コースが充実。実践的なプロジェクト構築を通じて習得できる。セール時は大幅割引。