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>
  )
}

パフォーマンス最適化のポイント

  1. use clientを最小化: クライアントコンポーネントの境界を葉ノードに押し込む
  2. dynamic()で重いコンポーネントを遅延読み込み: チャートライブラリ等
  3. next/imagepriority: Above-the-fold画像にはpriority設定
  4. 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の両面で大きな恩恵を受けられます。特に重要な順に習得するなら:

  1. サーバー/クライアントコンポーネントの使い分け
  2. Server Actions でフォーム処理を簡潔に
  3. Suspense + Streaming でUXを向上
  4. キャッシュ戦略でパフォーマンスを最大化

UI改善に役立つツールとして、DevToolBoxのカラーコントラストチェッカー(WCAG準拠確認)やCSS単位変換ツールも活用してみてください。


スキルアップ・キャリアアップのおすすめリソース

Next.js App Routerのスキルを活かして、さらなるキャリアアップを目指したい方へ。

転職・キャリアアップ

  • レバテックキャリア — ITエンジニア専門の転職エージェント。Next.js案件は国内でも急増中。フルスタックエンジニアとしての市場価値を高めやすい。無料相談可能。
  • Findy — GitHubスキル偏差値でNext.jsの実力をアピール。スカウト型でリモート・高単価の求人が多い。

オンライン学習

  • Udemy — Next.js App RouterやServer Actionsに特化した最新コースが充実。実践的なプロジェクト構築を通じて習得できる。セール時は大幅割引。

関連記事