言語を切り替える
テーマを切り替える

Next.js API Routes 完全ガイド:Route Handlers からエラー処理のベストプラクティスまで

先週の金曜日の午後、プロダクトマネージャーがやってきて言いました。「ユーザー登録 API を追加できる?」私はプロジェクトの pages/api フォルダを開き、いつもの手順で書こうとしましたが、フォルダは空でした。そこで思い出しました。これは App Router を使った新しいプロジェクトで、API の書き方が完全に変わっていたのです。

Next.js のドキュメントを開き、「Route Handlers」という言葉を見て、また新しい概念かと身構えました。午後一杯かけてドキュメントを読み、サンプルを追いながら、ようやく route.ts とは何か、なぜ慣れ親しんだ reqres が使えなくなったのかを理解しました。

Next.js のバックエンド API の書き方に戸惑っているなら、この記事が思考の整理に役立つはずです。Pages Router と App Router の API の違いを比較し、リクエスト処理、レスポンス設計、きれいなエラー処理を実践例で解説します。この記事を読み終える頃には、Next.js で自信を持ってバックエンド API を書けるようになっているでしょう。

API Routes の基礎:2 つの書き方の本質的な違い

Pages Router 時代の書き方

Next.js 13 以前は、pages/api フォルダに API を書いていました。当時の書き方は Express に似ており、Node.js の reqres オブジェクトを使います。

// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  res.status(200).json({ message: 'Hello from Pages Router!' })
}

この書き方の利点は、Node.js や Express の経験があればすぐに書けることです。欠点もあります。Node.js 固有の API に依存するため、Edge Runtime などのエッジ環境へのデプロイで問題が起きやすいのです。

App Router 時代の Route Handlers

Next.js 13 で App Router が導入され、API の書き方が根本的に変わりました。app ディレクトリ内に route.ts を作成し、Web 標準の RequestResponse API を使います。

// app/api/hello/route.ts
export async function GET(request: Request) {
  return Response.json({ message: 'Hello from Route Handlers!' })
}

初めてこの書き方を見たとき、私も違和感を覚えました。「なぜ reqres が使えないの?」と。変更には明確な理由があります。

  1. Web 標準への準拠:ブラウザネイティブの RequestResponse API を使うことで、コードの汎用性が高まり、現代の Web 開発の流れに沿います
  2. より良い型安全性:TypeScript のサポートが強化され、追加の型定義が不要になります
  3. Edge Runtime のサポート:Vercel Edge や Cloudflare Workers などのエッジ環境にデプロイでき、応答速度が向上します

核心的な違いの比較

特性Pages RouterApp Router
ファイル位置pages/api/*app/*/route.ts
API 設計Node.js req/resWeb 標準 Request/Response
HTTP メソッドデフォルトエクスポートで req.method を分岐各メソッドを個別エクスポート(GET、POST など)
キャッシュ動作キャッシュしないGET リクエストはデフォルトでキャッシュ

正直、最初は変更の理由がよくわかりませんでした。しばらく使ってみると、新しい書き方の方が明確だと気づきました。特に異なる HTTP メソッドを処理するとき、if (req.method === 'GET') のような分岐を書く必要がなくなります。

Route Handlers 実戦:HTTP リクエストの作成と処理

サポートされる HTTP メソッド

Route Handlers は 7 つの HTTP メソッドをサポートします:GETPOSTPUTPATCHDELETEHEADOPTIONS。各メソッドは名前付きエクスポートに対応しており、この設計は好きです。コード構造を見るだけで、その API がどの操作をサポートしているかが一目瞭然だからです。

以下は完全なユーザー管理 API の例です。

// app/api/users/route.ts

// ユーザーリストの取得
export async function GET(request: Request) {
  // URL からクエリパラメータを取得
  const { searchParams } = new URL(request.url)
  const page = searchParams.get('page') || '1'

  return Response.json({
    users: [
      { id: 1, name: '佐藤' },
      { id: 2, name: '鈴木' }
    ],
    page: parseInt(page)
  })
}

// 新規ユーザー作成
export async function POST(request: Request) {
  // JSON リクエストボディを解析
  const body = await request.json()

  return Response.json({
    id: 3,
    name: body.name
  }, { status: 201 })
}

リクエストデータの処理

Next.js Route Handlers は、リクエストデータを取得する方法がいくつかあります。最初は混乱しましたが、整理すると次のとおりです。

  1. URL パラメータrequest.urlURL オブジェクトを使う
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
  1. リクエストボディ(JSON)await request.json() を使う
const body = await request.json()
console.log(body.email) // メールフィールドを取得
  1. リクエストボディ(FormData)await request.formData() を使う
const formData = await request.formData()
const file = formData.get('avatar')
  1. ヘッダーと Cookienext/headers からインポート
import { headers, cookies } from 'next/headers'

export async function GET() {
  const headersList = headers()
  const cookieStore = cookies()

  const token = headersList.get('authorization')
  const userId = cookieStore.get('user_id')

  // ...
}
  1. 動的ルートパラメータ:関数の第 2 引数から取得
// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const userId = params.id
  return Response.json({ userId })
}

ここには落とし穴があります。Next.js 15 以降では params が非同期になり、await params.id が必要になる場合があります。ただし、多くのケースではそのまま使え、TypeScript が適宜警告してくれます。

レスポンスの構築

JSON を返すのが最も一般的で、Response.json() を使います。

export async function GET() {
  return Response.json({
    success: true,
    data: { message: '操作成功' }
  })
}

カスタムステータスコードやレスポンスヘッダーの設定も簡単です。

export async function POST(request: Request) {
  const body = await request.json()

  // パラメータ検証失敗時は 400 を返す
  if (!body.email) {
    return Response.json(
      { error: 'メールアドレスは必須です' },
      { status: 400 }
    )
  }

  // 作成成功時は 201 とレスポンスヘッダーを返す
  return Response.json(
    { id: 123, email: body.email },
    {
      status: 201,
      headers: {
        'X-Request-Id': 'abc-123',
        'Cache-Control': 'no-cache'
      }
    }
  )
}

実践例:ユーザー登録 API

ここまでの知識を組み合わせて、完全なユーザー登録 API を書いてみましょう。

// app/api/auth/register/route.ts
import { headers } from 'next/headers'

export async function POST(request: Request) {
  // リクエストヘッダーを取得
  const headersList = headers()
  const contentType = headersList.get('content-type')

  // Content-Type を確認
  if (!contentType?.includes('application/json')) {
    return Response.json(
      { error: 'JSON 形式で送信してください' },
      { status: 400 }
    )
  }

  // リクエストボディを解析
  const body = await request.json()
  const { username, email, password } = body

  // 基本検証
  if (!username || !email || !password) {
    return Response.json(
      { error: 'ユーザー名、メールアドレス、パスワードは必須です' },
      { status: 400 }
    )
  }

  // ここでデータベースにユーザーを保存する
  // const user = await db.user.create({ username, email, password })

  // 成功レスポンスを返す
  return Response.json({
    success: true,
    data: {
      id: 1,
      username,
      email
    }
  }, { status: 201 })
}

この例は、ヘッダー確認、JSON 解析、パラメータ検証、エラー処理、成功レスポンスをカバーしています。ほとんどの API はこのパターンに沿います。

エラー処理のベストプラクティス:API をより安定させる

Try-Catch の正しい使い方

API を書き始めた頃、私はすべてのロジックを巨大な try-catch で囲んでいました。

// ❌ 非推奨:大きな try-catch で全体を囲む
export async function POST(request: Request) {
  try {
    const body = await request.json()
    // ビジネスロジックが続く...
    return Response.json({ success: true })
  } catch (error) {
    return Response.json({ error: '操作失敗' }, { status: 500 })
  }
}

こう書くと、すべてのエラーが 500 になり、フロントエンドは詳細を取得できず、デバッグがつらくなります。操作ごとに分けて処理するように変えました。

// ✅ 推奨:エラーの種類を区別する
export async function POST(request: Request) {
  let body

  // JSON 解析エラーを個別に処理
  try {
    body = await request.json()
  } catch (error) {
    return Response.json(
      { error: 'リクエスト形式が不正です。JSON 形式を確認してください' },
      { status: 400 }
    )
  }

  // 検証エラーは try-catch 不要で直接返す
  if (!body.email || !body.password) {
    return Response.json(
      { error: 'メールアドレスとパスワードは必須です' },
      { status: 400 }
    )
  }

  // データベース操作エラーを個別に処理
  try {
    const user = await db.user.create(body)
    return Response.json({ success: true, data: user })
  } catch (error) {
    // 重複メールアドレスかどうかを確認
    if (error.code === 'P2002') {
      return Response.json(
        { error: 'このメールアドレスは既に登録されています' },
        { status: 409 }
      )
    }

    // その他のデータベースエラー
    console.error('Database error:', error)
    return Response.json(
      { error: 'サーバーエラーが発生しました。しばらくしてから再試行してください' },
      { status: 500 }
    )
  }
}

こう変えると、エラーメッセージが明確になり、フロントエンドもステータスコードに応じて処理できます。

構造化されたエラーレスポンス

多くのプロジェクトで、エラーレスポンス形式がバラバラだと経験しました。{ error: '...' } だったり { message: '...' } だったり { msg: '...' } だったり。フロントエンドとの連携が非常につらくなります。

その後、次の標準形式をまとめました。

// 統一されたエラーレスポンス形式
interface ErrorResponse {
  success: false
  error: string          // ユーザー向けのわかりやすいエラーメッセージ
  code?: string          // フロントエンドの i18n 用エラーコード
  details?: any          // 詳細情報(開発環境用)
  requestId?: string     // リクエスト追跡 ID
}

// 統一された成功レスポンス形式
interface SuccessResponse<T> {
  success: true
  data: T
  requestId?: string
}

実際には、ヘルパー関数を用意すると便利です。

// lib/api-response.ts
import { nanoid } from 'nanoid'

export function successResponse<T>(data: T, status: number = 200) {
  return Response.json({
    success: true,
    data,
    requestId: nanoid()
  }, { status })
}

export function errorResponse(
  error: string,
  status: number = 500,
  code?: string,
  details?: any
) {
  const isDev = process.env.NODE_ENV === 'development'

  return Response.json({
    success: false,
    error,
    code,
    details: isDev ? details : undefined, // 本番環境では詳細を返さない
    requestId: nanoid()
  }, { status })
}

こう書くと、API の実装がすっきりします。

// app/api/users/[id]/route.ts
import { successResponse, errorResponse } from '@/lib/api-response'

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const userId = params.id

  try {
    const user = await db.user.findUnique({ where: { id: userId } })

    if (!user) {
      return errorResponse('ユーザーが存在しません', 404, 'USER_NOT_FOUND')
    }

    return successResponse(user)
  } catch (error) {
    return errorResponse(
      'ユーザー情報の取得に失敗しました',
      500,
      'INTERNAL_ERROR',
      error
    )
  }
}

予想されるエラー vs 予期せぬエラー

API を書いていると、エラーは大きく 2 種類に分けられると気づきました。

  1. 予想されるエラー:パラメータ形式の誤り、リソース不存在、権限不足——正常なビジネスロジックで、4xx ステータスコードを返すべき
  2. 予期せぬエラー:データベース接続失敗、外部サービス障害、コードのバグ——システムレベルのエラーで、5xx ステータスコードを返すべき

2 種類のエラーへの対処も異なります。

export async function POST(request: Request) {
  const body = await request.json()

  // 予想されるエラー:ログ不要で直接返す
  if (!body.email?.includes('@')) {
    return errorResponse('メールアドレスの形式が正しくありません', 400, 'INVALID_EMAIL')
  }

  try {
    // 外部 API を呼び出す
    const response = await fetch('https://api.example.com/verify', {
      method: 'POST',
      body: JSON.stringify({ email: body.email })
    })

    if (!response.ok) {
      // 外部 API のエラーは予想されるエラー
      return errorResponse('メールアドレスの検証に失敗しました', 400, 'VERIFICATION_FAILED')
    }

    return successResponse({ verified: true })
  } catch (error) {
    // ネットワークエラー、タイムアウトなどは予期せぬエラー
    console.error('Unexpected error:', error) // ログに記録

    // Sentry などの監視システムに送ることもできる
    // Sentry.captureException(error)

    return errorResponse(
      'サービスが一時的に利用できません。しばらくしてから再試行してください',
      503,
      'SERVICE_UNAVAILABLE'
    )
  }
}

機密情報の漏洩を避ける

初期のプロジェクトで、データベースのエラーメッセージをそのままフロントエンドに返してしまい、テーブル構造が露出したことがあります。正しいやり方は次のとおりです。

try {
  const user = await db.user.create(body)
  return successResponse(user)
} catch (error) {
  // ❌ 危険:生のエラーをそのまま返す
  // return errorResponse(error.message, 500)

  // ✅ 安全:汎用エラーを返し、詳細はログのみ
  console.error('Database error:', {
    error,
    userId: request.headers.get('user-id'),
    timestamp: new Date().toISOString()
  })

  return errorResponse(
    'ユーザーの作成に失敗しました。しばらくしてから再試行してください',
    500,
    'CREATE_USER_FAILED'
  )
}

本番環境と開発環境も区別します。

const isDev = process.env.NODE_ENV === 'development'

return Response.json({
  success: false,
  error: '操作に失敗しました',
  // 開発環境のみ詳細エラーを返す
  stack: isDev ? error.stack : undefined,
  details: isDev ? error : undefined
}, { status: 500 })

レスポンス形式設計:フロントエンドとバックエンドの協業の鍵

RESTful API 設計の原則

API を設計するとき、RESTful の原則に従う習慣があります。厳密に守る必要はありませんが、このルールに沿うと API は理解しやすくなります。

核心は次のとおりです。

  1. HTTP メソッドで操作を表現

    • GET:リソースの取得
    • POST:リソースの作成
    • PUT/PATCH:リソースの更新
    • DELETE:リソースの削除
  2. URL でリソースを表現

    • /api/users — ユーザーリスト
    • /api/users/123 — ID が 123 のユーザー
    • /api/users/123/posts — そのユーザーの投稿
  3. ステータスコードで結果を表現

    • 200:成功
    • 201:作成成功
    • 400:クライアントのパラメータエラー
    • 401:未ログイン
    • 403:権限なし
    • 404:リソース不存在
    • 500:サーバーエラー

例として、ユーザー管理 API は次のように設計できます。

// app/api/users/route.ts
export async function GET(request: Request) {
  // GET /api/users?page=1&limit=20
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '20')

  const users = await db.user.findMany({
    skip: (page - 1) * limit,
    take: limit
  })

  return Response.json({ success: true, data: users })
}

export async function POST(request: Request) {
  // POST /api/users
  const body = await request.json()
  const user = await db.user.create({ data: body })

  return Response.json(
    { success: true, data: user },
    { status: 201 } // ここは 201 を使う
  )
}

// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  // GET /api/users/123
  const user = await db.user.findUnique({ where: { id: params.id } })

  if (!user) {
    return Response.json(
      { success: false, error: 'ユーザーが存在しません' },
      { status: 404 }
    )
  }

  return Response.json({ success: true, data: user })
}

export async function PATCH(
  request: Request,
  { params }: { params: { id: string } }
) {
  // PATCH /api/users/123
  const body = await request.json()
  const user = await db.user.update({
    where: { id: params.id },
    data: body
  })

  return Response.json({ success: true, data: user })
}

export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  // DELETE /api/users/123
  await db.user.delete({ where: { id: params.id } })

  return Response.json({ success: true, data: null })
}

統一されたレスポンス形式

エラー処理の章で触れた統一レスポンス形式を、ここでもう少し補足します。私が今使っている形式は次のとおりです。

// 基本レスポンスタイプ
type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string; code?: string }

// ページネーションレスポンス
interface PaginatedResponse<T> {
  success: true
  data: T[]
  pagination: {
    page: number
    limit: number
    total: number
    totalPages: number
  }
}

// リストレスポンスの例
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '20')

  const [users, total] = await Promise.all([
    db.user.findMany({ skip: (page - 1) * limit, take: limit }),
    db.user.count()
  ])

  return Response.json({
    success: true,
    data: users,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit)
    }
  })
}

TypeScript 型定義

フロントエンドとバックエンドの両方で TypeScript を使うなら、型定義も共有しましょう。プロジェクト内に types フォルダを作るのが一般的です。

// types/api.ts
export interface User {
  id: string
  username: string
  email: string
  createdAt: string
}

export interface CreateUserRequest {
  username: string
  email: string
  password: string
}

export interface CreateUserResponse {
  success: true
  data: User
}

// types/api-client.ts
import type { CreateUserRequest, CreateUserResponse } from './api'

export async function createUser(data: CreateUserRequest) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  })

  const result: CreateUserResponse = await response.json()

  if (!result.success) {
    throw new Error(result.error)
  }

  return result.data
}

こうすると、フロントエンドで API を呼び出すときに完全な型補完が効き、開発体験が大きく向上します。

よくある問題と解決策

GET リクエストのデフォルトキャッシュ問題

これが最もハマった落とし穴です。ユーザー情報を取得する API を書いたとき、ローカルでは問題なく動き、デプロイ後にユーザー情報を更新してもフロントエンドが古いデータを返し続けました。半日調査して、Next.js の GET リクエストがデフォルトでキャッシュされることが原因だと判明しました。

なぜこうなっているのか。Next.js チームの考えは、多くの GET リクエストが静的データを返すため、キャッシュでパフォーマンスを上げたい、というものです。実際の開発では、ほとんどの GET リクエストは動的でリアルタイムデータが必要です。

解決策は簡単で、route.ts に 1 行追加するだけです。

// app/api/users/route.ts
export const dynamic = 'force-dynamic' // キャッシュを無効化

export async function GET() {
  const users = await db.user.findMany()
  return Response.json({ success: true, data: users })
}

他にも次の方法があります。

// 方法 2:revalidate を 0 に設定
export const revalidate = 0

// 方法 3:レスポンスヘッダーで Cache-Control を設定
export async function GET() {
  const users = await db.user.findMany()

  return Response.json(
    { success: true, data: users },
    {
      headers: {
        'Cache-Control': 'no-store, max-age=0'
      }
    }
  )
}

いつキャッシュを使うべきか。国リストやカテゴリリストなど、ほとんど変わらないデータを返す API なら、キャッシュを活用できます。

// app/api/countries/route.ts
export const revalidate = 3600 // 1 時間キャッシュ

export async function GET() {
  const countries = await db.country.findMany()
  return Response.json({ success: true, data: countries })
}

デプロイ後の API 404 問題

ローカルでは正常、Vercel や Netlify にデプロイすると API がすべて 404 になる——この問題は何度も経験しました。原因は通常次のとおりです。

  1. ファイル名の誤りroute.ts または route.js である必要があり、api.ts などは不可
  2. 配置の誤りroute.tspage.tsx と同じフォルダに置けない
  3. git 未コミット.gitignore を確認し、route ファイルがコミットされているか確認

チェックリスト:

# ✅ 正しい構造
app/
  api/
    users/
      route.ts          # 正しい:GET /api/users
    users/
      [id]/
        route.ts        # 正しい:GET /api/users/123

# ❌ 誤った構造
app/
  api/
    users.ts            # 誤り:route.ts であるべき
  users/
    page.tsx
    route.ts            # 誤り:page.tsx と同じ階層に置けない

見落としがちな点として、next.config.jsapp ディレクトリが除外されていないか確認してください。

// next.config.js
module.exports = {
  // この設定があると app ディレクトリが無視される
  // pageExtensions: ['page.tsx', 'page.ts'],
}

try-catch 内での Redirect 問題

Next.js の redirect() 関数は、内部で特別なエラーを投げてリダイレクトを実現します。try-catch 内で呼ぶと、リダイレクトが catch されて正常に動作しません。

import { redirect } from 'next/navigation'

// ❌ 誤った書き方
export async function GET() {
  try {
    const isLoggedIn = await checkAuth()

    if (!isLoggedIn) {
      redirect('/login') // このリダイレクトが catch される
    }

    return Response.json({ success: true })
  } catch (error) {
    return Response.json({ error: '操作失敗' }, { status: 500 })
  }
}

// ✅ 正しい書き方
export async function GET() {
  const isLoggedIn = await checkAuth()

  if (!isLoggedIn) {
    redirect('/login') // try-catch の外で呼ぶ
  }

  try {
    const data = await fetchData()
    return Response.json({ success: true, data })
  } catch (error) {
    return Response.json({ error: '操作失敗' }, { status: 500 })
  }
}

ただし、Route Handlers で redirect() を使う場面は多くなく、401 ステータスを返してフロントエンドに遷移を任せる方が一般的です。

Route Handlers が不要な場面

App Router を初めて触ったとき、すべてのデータ取得に API を書く必要があると思っていました。後から Server Components がバックエンドロジックを直接呼べることに気づき、HTTP リクエストを経由する必要がないケースが多いとわかりました。

例えば次のような場面です。

// ❌ 非推奨:API を書いてからコンポーネントで呼ぶ
// app/api/posts/route.ts
export async function GET() {
  const posts = await db.post.findMany()
  return Response.json({ success: true, data: posts })
}

// app/blog/page.tsx
async function BlogPage() {
  const res = await fetch('http://localhost:3000/api/posts')
  const { data } = await res.json()

  return <div>{/* 記事リストをレンダリング */}</div>
}

// ✅ 推奨:Server Component で直接照会
// app/blog/page.tsx
async function BlogPage() {
  const posts = await db.post.findMany() // 直接データベースを照会

  return <div>{/* 記事リストをレンダリング */}</div>
}

Route Handlers が必要な場面:

  1. 外部呼び出し:モバイルアプリや外部サービス向け API
  2. Webhook:外部サービスからのコールバック受信
  3. クライアントコンポーネントからのデータ変更:フォーム送信、削除操作など
  4. 複雑なビジネスロジック:ファイルアップロード、複数の外部 API 呼び出し

Route Handlers が不要な場面:

  1. Server Components でのデータ取得:直接データベースを照会する方が速い
  2. シンプルなフォーム送信:Server Actions の方が簡単
  3. 内部ページ遷移:Next.js のルーティングで十分

応用テクニック:API をよりプロフェッショナルに

入力検証

これまでの例では、パラメータ検証を手書きの if で行っていましたが、面倒です。今は Zod を使っています。

import { z } from 'zod'

// 検証ルールを定義
const createUserSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  password: z.string().min(8),
  age: z.number().min(18).optional()
})

export async function POST(request: Request) {
  const body = await request.json()

  // データを検証
  const result = createUserSchema.safeParse(body)

  if (!result.success) {
    return Response.json({
      success: false,
      error: 'データ検証に失敗しました',
      details: result.error.errors // 詳細な検証エラーを返す
    }, { status: 400 })
  }

  // result.data は検証済みで型安全
  const user = await db.user.create({ data: result.data })

  return Response.json({ success: true, data: user }, { status: 201 })
}

Zod の利点は、検証と型定義が一体化していることです。

// schema から TypeScript 型を推論
type CreateUserInput = z.infer<typeof createUserSchema>
// 次と同等:
// type CreateUserInput = {
//   username: string
//   email: string
//   password: string
//   age?: number
// }

ミドルウェアパターン

いくつか API を書くと、認証、ログ記録、エラー処理などの重複コードが目立ちます。ミドルウェアとして抽象化できます。

// lib/middleware.ts
type RouteHandler = (request: Request, context: any) => Promise<Response>

// 認証ミドルウェア
export function withAuth(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    const token = request.headers.get('authorization')

    if (!token) {
      return Response.json(
        { success: false, error: '未ログイン' },
        { status: 401 }
      )
    }

    // トークンを検証
    const user = await verifyToken(token)

    if (!user) {
      return Response.json(
        { success: false, error: 'トークンが無効です' },
        { status: 401 }
      )
    }

    // ユーザー情報を handler に渡す
    context.user = user

    return handler(request, context)
  }
}

// ログミドルウェア
export function withLogging(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    const start = Date.now()
    const { method, url } = request

    console.log(`[${method}] ${url} - 処理開始`)

    const response = await handler(request, context)

    const duration = Date.now() - start
    console.log(`[${method}] ${url} - 完了 (${duration}ms)`)

    return response
  }
}

// 組み合わせて使用
// app/api/profile/route.ts
import { withAuth, withLogging } from '@/lib/middleware'

async function getProfile(request: Request, context: any) {
  const user = context.user // ミドルウェアからユーザー情報を取得

  return Response.json({ success: true, data: user })
}

export const GET = withLogging(withAuth(getProfile))

このパターンは実用的で、Zod 検証と組み合わせて使います。

// lib/middleware.ts
export function withValidation<T>(
  schema: z.Schema<T>,
  handler: (request: Request, data: T, context: any) => Promise<Response>
): RouteHandler {
  return async (request, context) => {
    const body = await request.json()
    const result = schema.safeParse(body)

    if (!result.success) {
      return Response.json({
        success: false,
        error: 'データ検証に失敗しました',
        details: result.error.errors
      }, { status: 400 })
    }

    return handler(request, result.data, context)
  }
}

// 使用例
export const POST = withAuth(
  withValidation(createUserSchema, async (request, data, context) => {
    // data は検証済みで型安全
    const user = await db.user.create({ data })
    return Response.json({ success: true, data: user }, { status: 201 })
  })
)

Edge Runtime の選択

Next.js は Node.js Runtime と Edge Runtime の 2 つをサポートします。多くの場合、デフォルトの Node.js Runtime で十分です。グローバルに高速応答が必要な API なら Edge Runtime を検討できます。

// app/api/hello/route.ts
export const runtime = 'edge' // Edge Runtime を指定

export async function GET() {
  return Response.json({ message: 'Hello from Edge!' })
}

Edge Runtime の利点は、コードがユーザーに最も近いエッジノードにデプロイされ、応答が速いことです。制限もあります。

  1. Node.js API が使えないfspath などのモジュールは不可
  2. 従来型データベースに接続できない:Prisma Data Proxy、PlanetScale など HTTP 接続対応 DB が必要
  3. パッケージサイズ制限:コードが大きすぎるとデプロイできない

Edge Runtime を使う場面:

  • シンプルな API で複雑な依存がない
  • 読み取りが多く書き込みが少ない
  • グローバル低レイテンシが必要

Node.js Runtime を使う場面:

  • 従来型データベースへの接続が必要
  • Node.js エコシステムのライブラリを使う
  • 複雑なビジネスロジック

正直、私のプロジェクトの大半はデフォルトの Node.js Runtime です。Edge Runtime は特定の場面向けと考えています。

まとめ

ここまで、Next.js API Routes の核心を一通り見てきました。振り返ると次のとおりです。

  • 書き方の変化:Pages Router の req/res から App Router の Request/Response へ、Web 標準への移行
  • Route Handlers:各 HTTP メソッドを個別エクスポートし、GET、POST、PUT、PATCH、DELETE などをサポート
  • リクエスト処理:URL パラメータ、JSON body、FormData、Headers、Cookies、動的ルートパラメータ——さまざまなデータ取得方法
  • エラー処理:予想されるエラーと予期せぬエラーを区別し、統一レスポンス形式を使い、機密情報の漏洩を防ぐ
  • レスポンス設計:RESTful 原則に従い、ステータスコードを意味づけ、TypeScript 型を共有
  • よくある落とし穴:GET キャッシュ、デプロイ 404、try-catch 内 redirect の失効、Route Handlers の過剰使用

Next.js の API 書き方は Pages Router から App Router へ大きく変わり、最初は戸惑うかもしれません。しばらく使うと、型安全性、コードの明確さ、デプロイの柔軟性など、新しい書き方のメリットが実感できるはずです。

次にできること

  1. すぐに試す:既存プロジェクトの API を 1 つ Route Handlers で書き直し、新旧の違いを体感する
  2. テンプレートを整える:この記事のエラー処理とレスポンス形式のコードをベースに、自分用の API テンプレートを作る
  3. 継続的に学ぶ:Next.js は急速に進化しているので、公式ドキュメントを追い、Server Actions や Middleware などの新機能も把握する

API 設計に銀の弾丸はありません。この記事の方案が唯一の正解ではありません。プロジェクトの実情に合わせて柔軟に調整し、チームに最適な方法を見つけることが大切です。

この記事が役に立ったら、ブックマークしておいてください。困ったときに見返せます。Next.js 開発がうまくいくことを祈っています!

FAQ

Route Handlers と Pages Router API Routes の主な違いは何ですか?
主な違いは 3 点です。1) Route Handlers は Web 標準の Request/Response API を使用し、Pages Router は Node.js の req/res を使う。2) 各 HTTP メソッドを個別にエクスポートするため、req.method を手動で判定する必要がない。3) GET リクエストはデフォルトでキャッシュされるため、動的データが必要な場合は dynamic='force-dynamic' で無効化する必要がある。
GET リクエストが最新のデータを返さないのはなぜですか?
Next.js App Router の GET リクエストはデフォルトでキャッシュされます。解決策は、route.ts に export const dynamic = 'force-dynamic' を追加するか、レスポンスヘッダーに Cache-Control: no-store を設定することです。静的データを返す API だけがキャッシュに向いています。
いつ Route Handlers を使い、いつ Server Components を使うべきですか?
Route Handlers が必要な場面:外部 API 呼び出し、Webhook、クライアントコンポーネントからのデータ変更、ファイルアップロード。不要な場面:Server Components でデータを取得する場合は直接データベースを照会でき、シンプルなフォーム送信は Server Actions の方が便利です。
API エラーをどうすればきれいに処理できますか?
予想されるエラー(4xx)と予期せぬエラー(5xx)を区別し、{ success, error, code, requestId } の統一レスポンス形式を使います。操作ごとに try-catch を分け、大きな try-catch で全体を囲まない。本番環境では機密情報を返さず、ログに記録します。
デプロイ後に API がすべて 404 になるのに、ローカルでは正常な場合はどうすればいいですか?
3 点を確認してください。1) ファイル名は route.ts または route.js であること。2) route.ts を page.tsx と同じフォルダに置かないこと。3) ファイルが git にコミットされており、next.config.js で app ディレクトリが除外されていないこと。よくある原因はファイル名や配置の誤りです。

5分で読めます · 公開日: 2026年1月5日 · 更新日: 2026年6月8日

関連記事

コメント

GitHubアカウントでログインしてコメントできます