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

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. ヘッダーと Cookie: next/headers からインポート
import { headers, cookies } from 'next/headers'

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

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

注意: Next.js 15 以降、headers と cookies は非同期関数になりました。

  1. 動的ルートパラメータ: 関数の第2引数から取得
// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  // Next.js 15 以降 params は非同期になる可能性があるため、必要に応じて await
  const userId = params.id
  return Response.json({ userId })
}

レスポンスの構築

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

  if (!body.email) {
    return Response.json(
      { error: 'メールアドレスは必須です' },
      { status: 400 }
    )
  }

  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 = await headers()
  const contentType = headersList.get('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 })
}

エラー処理のベストプラクティス

Try-Catch の正しい使い方

API を書き始めた頃、私はすべてのロジックを巨大な 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

  try {
    body = await request.json()
  } catch (error) {
    return Response.json(
      { error: 'JSON形式が不正です' },
      { status: 400 }
    )
  }

  if (!body.email) {
    return Response.json(
      { error: 'メールアドレスは必須です' },
      { status: 400 }
    )
  }

  try {
    const user = await db.user.create(body)
    return Response.json({ success: true, data: user })
  } catch (error: any) {
    if (error.code === 'P2002') { // Prisma のユニーク制約違反
      return Response.json(
        { error: 'このメールアドレスは既に登録されています' },
        { status: 409 }
      )
    }

    console.error('Database error:', error)
    return Response.json(
      { error: 'サーバー内部エラー' },
      { status: 500 }
    )
  }
}

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

エラーレスポンスの形式がバラバラだと、フロントエンドの実装が大変になります。統一されたフォーマットを定義しましょう。

interface ErrorResponse {
  success: false
  error: string
  code?: string
  details?: any
  requestId?: string
}

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

レスポンス形式設計

RESTful の原則に従い、URL でリソースを表現し、HTTP メソッドで操作を表現し、ステータスコードで結果を伝えるのが基本です。また、TypeScript を活用して型定義を共有すると、フロントエンドの実装が非常に楽になります。

types/api.ts のようなファイルでリクエストとレスポンスの型を定義し、クライアントとサーバーで共有することを強く推奨します。

よくある問題と解決策

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

私が最もハマったのがこれです。GET リクエストの結果がキャッシュされ、データ更新が反映されない現象です。Next.js の App Router は、静的と判断できる GET リクエストを積極的にキャッシュします。

解決策は、動的であることを明示することです:

  1. export const dynamic = 'force-dynamic' を追加する
  2. export const revalidate = 0 を設定する
  3. Request オブジェクトを使用する(クエリパラメータや Cookie の読み取りなど)
// 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 })
}

デプロイ後の 404 エラー

ローカルでは動くのにデプロイすると 404 になる場合、以下を確認してください:

  1. ファイル名が route.ts または route.js であること。api.ts は不可。
  2. page.tsx と同じディレクトリに route.ts を置かないこと(競合します)。
  3. ファイルが git にコミットされているか。

try-catch 内での Redirect

Next.js の redirect() 関数は内部で特別なエラーを投げることで動作します。これを try-catch ブロック内で呼ぶと、redirect が catch されてしまい、正常に機能しません。

// ❌ 間違い
try {
  if (!auth) redirect('/login')
} catch (e) {
  // ここに来てしまう
}

// ✅ 正解
if (!auth) redirect('/login')
try {
  // ...
} catch (e) {
  // ...
}

ただし、Route Handlers では通常 redirect よりも 401 ステータスを返す方が適切です。

結論

Route Handlers は、Next.js でバックエンド API を構築するための強力で標準に準拠した方法です。最初は変更に戸惑うかもしれませんが、Web 標準への回帰、型安全性の向上、エッジコンピューティングへの対応など、メリットは大きいです。

この記事で紹介したベストプラクティスやエラー処理パターンを活用して、より堅牢で保守性の高い API を構築してください。

FAQ

Route Handlers と Pages Router API Routes の主な違いは何ですか?
主な違いは3点です。1) Route Handlers は Web 標準の Request/Response API を使用します(Pages Router は Node.js の req/res)。2) HTTP メソッドごとに独立した関数をエクスポートします。3) GET リクエストはデフォルトでキャッシュされるため、動的データの場合は設定が必要です。
GET リクエストが最新のデータを返さないのはなぜですか?
Next.js App Router の GET リクエストはパフォーマンス最適化のためデフォルトでキャッシュされます。解決するには、route.ts に `export const dynamic = 'force-dynamic'` を追加するか、レスポンスヘッダーに `Cache-Control: no-store` を設定してキャッシュを無効化してください。
いつ Route Handlers を使用し、いつ Server Components を使用すべきですか?
外部からの呼び出し(モバイルアプリや Webhook)、クライアントコンポーネントからのデータ変更、ファイルアップロードなどには Route Handlers を使用します。単にアプリ内でデータを表示するだけなら、Server Components で直接データベースを叩く方が効率的です。
API エラーをどのように優雅に処理すべきですか?
予想されるエラー(4xx)と予期せぬエラー(5xx)を区別し、統一されたレスポンス形式({ success, error, code })を使用します。単一の try-catch で全体を囲むのではなく、操作ごとに適切なエラー処理を行い、本番環境では詳細なエラーログを隠蔽します。
デプロイ後、API が 404 になるのはなぜですか?
よくある原因は、ファイル名が `route.ts` 以外になっている、`page.tsx` と同じフォルダに置かれている(ルート競合)、またはファイルが git にコミットされていないことです。ファイル構造と設定を確認してください。

4 min read · 公開日: 2026年1月5日 · 更新日: 2026年1月22日

コメント

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

関連記事