Next.js: API ルート & ルートハンドラ

最終更新:2026-08-26

ルートハンドラは Next.js の API レイヤーを形成します — サーバーアクションでは不十分な場合、標準的な RESTful エンドポイントがモダン Web の礎石であり続けます。

1. 学習目標



2. ある DevOps エンジニアの実話

(1) 課題: チームが認証ロジックを5ページで8回実装してしまった

TaskFlow のコードをレビュー中、Diana はすべての API ルートが同じ15行の認証コードで始まっていることに気づきました — Cookie からセッションを解析し、トークンを検証し、401 レスポンスを返します。8つの API エンドポイント × 15行 = 120行の重複コードです。さらに悪いことに、3つのエンドポイントには認証チェックがなく、未認証ユーザーからのリクエストにユーザーデータを直接露出させていました。

問題 データ
重複した認証コード 120行 (8エンドポイント × 15行)
保護されていないエンドポイント 3
セキュリティ監査不合格 2回
修復時間 1回あたり4時間

(2) ミドルウェアによる解決策

middleware.ts を使用して認証、ログ記録、CORS を一元管理します — 単一のファイルですべてのリクエストのライフサイクル全体を制御します。

TS
// middleware.ts — シングルサインオン + ログ
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session-token')?.value

  // API リクエストの認証を検査
  if (request.nextUrl.pathname.startsWith('/api/')) {
    if (!token) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }
  }

  // セキュリティヘッダーを追加
  const response = NextResponse.next()
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')

  return response
}

export const config = {
  matcher: '/api/:path*',
}

(3) 成果

指標 導入前 (ミドルウェアなし) 導入後 (ミドルウェアあり)
認証コード 120行 (分散) 15行 (集約)
保護されていないエンドポイント 3 0
セキュリティ監査 ❌ 不合格 ✅ 合格
新規エンドポイントの作業量 認証15行 + ロジック ロジックのみ


3. ルートハンドラの基本

ルートハンドラは app/api/**/route.ts ファイルで定義され、各 HTTP メソッドに対応するエクスポート非同期関数を持ちます:

HTTP メソッド エクスポート関数 ルートファイル
GET export async function GET() app/api/items/route.ts
POST export async function POST() app/api/items/route.ts
PUT export async function PUT() app/api/items/[id]/route.ts
DELETE export async function DELETE() app/api/items/[id]/route.ts
PATCH export async function PATCH() app/api/items/[id]/route.ts

(1) 基本的な GET エンドポイント

TS
// app/api/items/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  const items = await db.item.findMany()
  return NextResponse.json(items)
}

(2) 動的ルーティングパラメータ

TS
// app/api/items/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const item = await db.item.findUnique({ where: { id: Number(params.id) } })
  if (!item) {
    return NextResponse.json({ error: 'アイテムが見つかりません' }, { status: 404 })
  }
  return NextResponse.json(item)
}

(3) POST でリソースを作成

TS
// app/api/items/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const body = await request.json()
  const item = await db.item.create({ data: body })
  return NextResponse.json(item, { status: 201 })
}

▶ サンプル: 完全な CRUD ルートハンドラ (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
POST app/api/items/route.ts → 新しいリソースを作成し、作成されたアイテムを 201 ステータスで返します。
TS
// app/api/todos/route.ts — GET + POST
import { NextRequest, NextResponse } from 'next/server'

const API = 'https://jsonplaceholder.typicode.com'

export async function GET() {
  const todos = await fetch(`${API}/todos?_limit=5`).then(r => r.json())
  return NextResponse.json(todos)
}

export async function POST(request: NextRequest) {
  const body = await request.json()
  const todo = await fetch(`${API}/todos`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  }).then(r => r.json())
  return NextResponse.json(todo, { status: 201 })
}
💻 出力:

TEXT 📖 参照専用
GET app/api/todos/route.ts → JSON データを 200 ステータスで返します。
POST app/api/todos/route.ts → 新しいリソースを作成し、作成されたアイテムを 201 ステータスで返します。
TS
// app/api/todos/[id]/route.ts — GET + PUT + DELETE
import { NextRequest, NextResponse } from 'next/server'

const API = 'https://jsonplaceholder.typicode.com'

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const todo = await fetch(`${API}/todos/${params.id}`).then(r => r.json())
  if (!todo || todo.id === undefined) {
    return NextResponse.json({ error: '見つかりません' }, { status: 404 })
  }
  return NextResponse.json(todo)
}

export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const body = await request.json()
  const updated = await fetch(`${API}/todos/${params.id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  }).then(r => r.json())
  return NextResponse.json(updated)
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  await fetch(`${API}/todos/${params.id}`, { method: 'DELETE' })
  return NextResponse.json({ success: true })
}


4. Zod リクエスト検証

REST API でのリクエストデータの検証にも Zod を推奨します — 手動の if チェックよりも簡潔で型安全です。

(1) リクエストボディ検証

TS
// app/api/items/route.ts — Zod による POST リクエストボディ検証
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

const createItemSchema = z.object({
  name: z.string().min(2).max(100),
  price: z.number().positive('価格は正の数である必要があります'),
  category: z.enum(['electronics', 'clothing', 'food']),
  tags: z.array(z.string()).max(5).optional(),
})

export async function POST(request: NextRequest) {
  const body = await request.json()
  const validated = createItemSchema.safeParse(body)

  if (!validated.success) {
    return NextResponse.json(
      { error: '検証に失敗しました', details: validated.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  const item = await db.item.create({ data: validated.data })
  return NextResponse.json(item, { status: 201 })
}

(2) クエリパラメータ検証

TS
// app/api/items/route.ts — Zod によるクエリパラメータ検証
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

const querySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(10),
  search: z.string().optional(),
})

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl
  const query = querySchema.safeParse({
    page: searchParams.get('page'),
    limit: searchParams.get('limit'),
    search: searchParams.get('search'),
  })

  if (!query.success) {
    return NextResponse.json(
      { error: '無効なクエリパラメータ', details: query.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  const { page, limit, search } = query.data
  const items = await db.item.findMany({
    skip: (page - 1) * limit,
    take: limit,
    where: search ? { name: { contains: search } } : undefined,
  })
  return NextResponse.json({ items, page, limit })
}

▶ サンプル: Zod 検証の完全な API (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
GET app/api/items/route.ts → Zod でクエリパラメータを検証し、フィルタリングされた結果を JSON で返します。
TS
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { revalidateTag } from 'next/cache'

const userSchema = z.object({
  name: z.string().min(2, '名前が短すぎます'),
  email: z.string().email('メールアドレスが無効です'),
  role: z.enum(['admin', 'user', 'viewer']).default('user'),
})

export async function POST(request: NextRequest) {
  const body = await request.json()
  const validated = userSchema.safeParse(body)

  if (!validated.success) {
    return NextResponse.json(
      { error: '検証に失敗しました', fields: validated.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  const user = await fetch('https://jsonplaceholder.typicode.com/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(validated.data),
  }).then(r => r.json())

  revalidateTag('users')
  return NextResponse.json(user, { status: 201 })
}
💻 出力:

TEXT 📖 参照専用
POST app/api/users/route.ts → Zod でリクエストボディを検証し、リソースを作成、201 を返します。


5. NextResponse レスポンス形式

NextResponse は Next.js が提供する専用のレスポンスツールで、複数のレスポンス形式をサポートします:

メソッド 目的
NextResponse.json(data, opts?) JSON レスポンス NextResponse.json({ id: 1 }, { status: 201 })
NextResponse.redirect(url) リダイレクト NextResponse.redirect(new URL('/login', request.url))
NextResponse.next() ミドルウェアチェーンを継続 return NextResponse.next()
NextResponse.rewrite(url) サーバーサイド URL 書き換え NextResponse.rewrite(new URL('/fallback', request.url))

(1) JSON レスポンス

TS
// app/api/auth/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  return NextResponse.json({ status: 'healthy', uptime: process.uptime() })
}

(2) リダイレクト

TS
// app/api/redirect/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const target = request.nextUrl.searchParams.get('to') ?? '/'
  return NextResponse.redirect(new URL(target, request.url))
}

(3) リライト (URL 書き換え)

TS
// middleware.ts — /products/old-slug を新しいパスに書き換え
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/products/old-')) {
    const newPath = request.nextUrl.pathname.replace('/old-', '/')
    return NextResponse.rewrite(new URL(newPath, request.url))
  }
  return NextResponse.next()
}

▶ サンプル: レスポンス形式の比較 (難易度: ⭐)

💻 出力:

TEXT 📖 参照専用
ミドルウェアがリクエストを傍受し、条件に基づいて URL を書き換えます。
TS
// app/api/response-demo/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const format = request.nextUrl.searchParams.get('format') ?? 'json'

  switch (format) {
    case 'json':
      return NextResponse.json({ message: 'Hello', timestamp: Date.now() })
    case 'redirect':
      return NextResponse.redirect(new URL('/api/response-demo?format=json', request.url))
    case 'rewrite':
      return NextResponse.rewrite(new URL('/api/response-demo?format=json', request.url))
    default:
      return NextResponse.json({ error: '不明な形式' }, { status: 400 })
  }
}
💻 出力:

TEXT 📖 参照専用
GET app/api/response-demo/route.ts → JSON データを 200 ステータスで返します。


6. ミドルウェア

middleware.ts は Next.js のリクエストインターセプターです — 各リクエストがページや API に到達する前に実行され、パスマッチング、リクエスト書き換え、ヘッダー注入、認証チェックをサポートします。

100%
sequenceDiagram
    participant Client as ブラウザ
    participant MW as middleware.ts
    participant Route as ルートハンドラ/ページ

    Client->>MW: リクエスト /api/todos
    MW->>MW: matcher ルールにマッチ
    MW->>MW: 認証検査 / ヘッダー注入
    alt 通過
        MW->>Route: NextResponse.next()
        Route-->>Client: 通常レスポンス
    else 未認証
        MW-->>Client: NextResponse.json(401)
    else リダイレクト
        MW-->>Client: NextResponse.redirect(/login)
    end
設定 説明
matcher string[] パスパターンのマッチング (glob 対応)
request.nextUrl URL 現在のリクエストの URL オブジェクト
request.cookies Map リクエスト Cookie
request.headers Headers リクエストヘッダー
NextResponse.next() Response 通常通り処理を継続
NextResponse.redirect() Response 別の URL にリダイレクト

(1) Matcher 設定

TS
// middleware.ts — matcher レイアウト
export const config = {
  matcher: [
    '/api/:path*',           // すべての API ルーティング
    '/dashboard/:path*',     // すべての dashboard ページ
    '/((?!_next|static|favicon.ico).*)',  // 静的リソースを除外
  ],
}

(2) よくあるミドルウェアパターン

TS
// middleware.ts — よくあるパターンの概要
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 1. 認証検査
  const token = request.cookies.get('session')?.value
  const isAuthPage = pathname.startsWith('/login') || pathname.startsWith('/register')

  if (!token && !isAuthPage && pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // 2. セキュリティヘッダー
  const response = NextResponse.next()
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')

  // 3. リクエスト ID を追加 (追跡)
  const requestId = crypto.randomUUID()
  response.headers.set('X-Request-Id', requestId)

  return response
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'],
}

▶ サンプル: ミドルウェアログ記録 (難易度: ⭐)

💻 出力:

TEXT 📖 参照専用
ミドルウェアがリクエストを傍受し、条件に基づいてリダイレクトします。
TS
// middleware.ts — リクエストログ
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const start = Date.now()
  const { method, nextUrl } = request

  console.log(`[${new Date().toISOString()}] ${method} ${nextUrl.pathname}`)

  const response = NextResponse.next()

  // 非同期レスポンス時間ログ記録
  response.headers.set('X-Response-Time', `${Date.now() - start}ms`)

  return response
}

export const config = {
  matcher: '/api/:path*',
}
💻 出力:

TEXT 📖 参照専用
[${new Date(


7. CORS とレート制限

(1) CORS 設定

TS
// app/api/cors-config/route.ts — 単一エンドポイントの CORS
import { NextRequest, NextResponse } from 'next/server'

const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://your-app.com',
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400',
}

export async function OPTIONS() {
  return NextResponse.json({}, { headers: corsHeaders })
}

export async function GET() {
  return NextResponse.json(
    { data: 'CORS enabled' },
    { headers: corsHeaders }
  )
}

(2) グローバル CORS ミドルウェア

TS
// middleware.ts — グローバル CORS
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const response = NextResponse.next()
    response.headers.set('Access-Control-Allow-Origin', '*')
    response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
    response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')

    // プリフライトリクエストは即時返却
    if (request.method === 'OPTIONS') {
      return new Response(null, { status: 204, headers: response.headers })
    }

    return response
  }
  return NextResponse.next()
}

export const config = {
  matcher: '/api/:path*',
}

(3) レート制限

TS
// lib/rate-limit.ts — シンプルなメモリレート制限
const rateMap = new Map<string, { count: number; resetAt: number }>()

export function rateLimit(key: string, maxRequests: number, windowMs: number): boolean {
  const now = Date.now()
  const record = rateMap.get(key)

  if (!record || now > record.resetAt) {
    rateMap.set(key, { count: 1, resetAt: now + windowMs })
    return true  // 許可
  }

  if (record.count >= maxRequests) {
    return false  // 制限
  }

  record.count++
  return true
}
TS
// app/api/rate-limited/route.ts — レート制限の使用
import { NextRequest, NextResponse } from 'next/server'
import { rateLimit } from '@/lib/rate-limit'

export async function GET(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
  const allowed = rateLimit(ip, 10, 60_000)  // 1分あたり10回

  if (!allowed) {
    return NextResponse.json(
      { error: 'リクエストが多すぎます' },
      { status: 429, headers: { 'Retry-After': '60' } }
    )
  }

  return NextResponse.json({ message: 'Success', timestamp: Date.now() })
}

▶ サンプル: 完全な CORS + レート制限 (難易度: ⭐⭐⭐)

💻 出力:

TEXT 📖 参照専用
GET app/api/rate-limited/route.ts → データを JSON で返します。悪用を防ぐためのレート制限付き。
TS
// app/api/secure/route.ts — CORS + レート制限 + 認証
import { NextRequest, NextResponse } from 'next/server'
import { rateLimit } from '@/lib/rate-limit'
import { z } from 'zod'

const postSchema = z.object({
  title: z.string().min(2).max(200),
  content: z.string().min(10),
})

export async function POST(request: NextRequest) {
  // 1. レート制限
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
  if (!rateLimit(ip, 20, 60_000)) {
    return NextResponse.json({ error: 'レート制限を超えました' }, { status: 429 })
  }

  // 2. 認証チェック
  const auth = request.headers.get('authorization')
  if (!auth?.startsWith('Bearer ') || auth.slice(7) !== process.env.API_KEY) {
    return NextResponse.json({ error: '認証されていません' }, { status: 401 })
  }

  // 3. 検証
  const body = await request.json()
  const validated = postSchema.safeParse(body)
  if (!validated.success) {
    return NextResponse.json(
      { error: '検証に失敗しました', details: validated.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  // 4. 処理
  const post = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    body: JSON.stringify({ ...validated.data, userId: 1 }),
  }).then(r => r.json())

  return NextResponse.json(post, { status: 201, headers: {
    'Access-Control-Allow-Origin': '*',
  }})
}

export async function OPTIONS() {
  return NextResponse.json({}, { headers: {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  }})
}
💻 出力:

TEXT 📖 参照専用
POST app/api/secure/route.ts → Zod でリクエストボディを検証し、リソースを作成、201 を返します。


8. 完全な例: RESTful API サービス

TS
// app/api/posts/route.ts — 投稿 CRUD API
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { revalidateTag } from 'next/cache'

// ======== スキーマ ========
const createPostSchema = z.object({
  title: z.string().min(2).max(200),
  body: z.string().min(10).max(5000),
  userId: z.number().int().positive(),
})

const querySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(50).default(10),
})

const API = 'https://jsonplaceholder.typicode.com'

// ======== GET /api/posts?page=1&limit=10 ========
export async function GET(request: NextRequest) {
  const query = querySchema.safeParse({
    page: request.nextUrl.searchParams.get('page'),
    limit: request.nextUrl.searchParams.get('limit'),
  })

  if (!query.success) {
    return NextResponse.json(
      { error: '無効なクエリ', details: query.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  const { page, limit } = query.data
  const posts = await fetch(`${API}/posts?_page=${page}&_limit=${limit}`).then(r => r.json())
  const total = 100  // JSONPlaceholder の合計

  return NextResponse.json({
    data: posts,
    pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
  })
}

// ======== POST /api/posts ========
export async function POST(request: NextRequest) {
  const body = await request.json()
  const validated = createPostSchema.safeParse(body)

  if (!validated.success) {
    return NextResponse.json(
      { error: '検証に失敗しました', fields: validated.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  const post = await fetch(`${API}/posts`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(validated.data),
  }).then(r => r.json())

  revalidateTag('posts')
  return NextResponse.json(post, { status: 201 })
}
TS
// app/api/posts/[id]/route.ts — 単一記事 CRUD
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

const updatePostSchema = z.object({
  title: z.string().min(2).max(200).optional(),
  body: z.string().min(10).max(5000).optional(),
})

const API = 'https://jsonplaceholder.typicode.com'

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const post = await fetch(`${API}/posts/${params.id}`).then(r => {
    if (!r.ok) throw new Error('見つかりません')
    return r.json()
  }).catch(() => null)

  if (!post) {
    return NextResponse.json({ error: '投稿が見つかりません' }, { status: 404 })
  }
  return NextResponse.json(post)
}

export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const body = await request.json()
  const validated = updatePostSchema.safeParse(body)

  if (!validated.success) {
    return NextResponse.json(
      { error: '検証に失敗しました', fields: validated.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  const updated = await fetch(`${API}/posts/${params.id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(validated.data),
  }).then(r => r.json())

  return NextResponse.json(updated)
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  await fetch(`${API}/posts/${params.id}`, { method: 'DELETE' })
  return NextResponse.json({ success: true, id: params.id })
}
TS
// middleware.ts — グローバル API ミドルウェア
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { rateLimit } from '@/lib/rate-limit'

export function middleware(request: NextRequest) {
  const { pathname, origin } = request.nextUrl

  if (!pathname.startsWith('/api/')) {
    return NextResponse.next()
  }

  // 1. レート制限
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
  if (!rateLimit(ip, 30, 60_000)) {
    return NextResponse.json({ error: 'リクエストが多すぎます' }, {
      status: 429,
      headers: { 'Retry-After': '60' }
    })
  }

  // 2. ログ記録
  console.log(`[API] ${request.method} ${pathname}`)

  // 3. CORS
  const response = NextResponse.next()
  response.headers.set('Access-Control-Allow-Origin', '*')
  response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
  response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')

  if (request.method === 'OPTIONS') {
    return new Response(null, { status: 204, headers: response.headers })
  }

  return response
}

export const config = {
  matcher: '/api/:path*',
}

❓ よくある質問

Q ルートハンドラとサーバーアクションの違いは何ですか?
A ルートハンドラは route.ts で定義される標準的な HTTP エンドポイントで、サードパーティ API 統合、Webhook、モバイル呼び出しに適しています。サーバーアクションは 'use server' で定義される RPC スタイルのサーバーサイド関数で、ファーストパーティのフォーム操作に適しています。主な違い: ルートハンドラは HTTP 呼び出しが必要ですが、サーバーアクションは関数呼び出しです。ルートハンドラには CSRF 保護が含まれませんが、サーバーアクションには組み込まれています。
Q NextResponse.json と単純な new Response() の違いは何ですか?
A NextResponse.jsonNextResponse の便利なメソッドで、Content-Type: application/json を自動設定し、より優れた TypeScript 型推論を提供します。new Response(JSON.stringify(data), { headers: {'Content-Type': 'application/json'} }) と同等です。コードの簡潔さのために NextResponse.json の使用を推奨します。
Q middleware.ts はデータベースを読み取れますか?
A はい。ただし、パフォーマンスに注意してください — ミドルウェアはマッチしたすべてのリクエストに対して実行されます。データベースクエリはレイテンシを増加させます。ミドルウェアでは軽量な操作のみを実行することを推奨します (Cookie の解析、ヘッダーのチェック、リダイレクトなど)。データベース操作はルートハンドラまたはサーバーアクションに配置する必要があります。
Q ルートハンドラは Edge Runtime をサポートしていますか?
A はい。デフォルトではルートハンドラは Node.js Runtime で実行されますが、export const runtime = 'edge' で Edge Runtime に切り替えられます。Edge Runtime はより低いレイテンシを提供しますが、API サポートが制限されています (fs や crypto などのネイティブ Node.js モジュールをサポートしません)。Edge Runtime は単純なプロキシ、認証検証、A/B テストに適しています。
Q API エンドポイントを悪用から保護するには?
A 3層の保護: ① ミドルウェア層での IP ベースのレート制限; ② ルートハンドラ層での API キー/JWT 認証; ③ 許可されたオリジンに対するグローバル CORS 制限。本番環境では、Redis ベースのレート制限 (Upstash/ratelimit など) を使用して、複数インスタンス間での状態共有をサポートすることを推奨します。
Q ルートハンドラでクライアントの IP アドレスを取得するには?
A request.headers を使用して取得します: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() または request.headers.get('x-real-ip')。Vercel はデプロイ時にこれらのヘッダーを自動的に追加します。ローカル開発時は ::1 または 127.0.0.1 が返される場合があります。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐): app/api/health/route.ts を作成し、JSON ヘルスチェック情報 (ステータス、タイムスタンプ、稼働時間) を返します。各 API 呼び出しとそのレスポンス時間をログに記録するミドルウェアを追加します。

  2. 応用問題 (⭐⭐): 完全な CRUD API を構築します: app/api/books/route.ts (GET 一覧 + POST 作成) + app/api/books/[id]/route.ts (GET 詳細表示 + PUT 更新 + DELETE 削除)。Zod を使用してリクエストボディを検証します。ミドルウェアに API キー認証を追加します。

  3. 発展問題 (⭐⭐⭐): 「短縮 URL サービス」API を実装します: app/api/shorten/route.ts (POST で URL を受け取り短縮 URL を返す)、app/api/[code]/route.ts (GET で短縮 URL を取得し 302 リダイレクトを実行)。レート制限 (IP ごとに1分あたり10件の短縮リンク)、CORS サポート、ミドルウェアアクセスログを追加します。短縮コードは JSON ファイルまたはメモリ内マップに保存します。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%