Next.js API Routes 完全ガイド:Route Handlers からエラー処理のベストプラクティスまで
先週の金曜日の午後、プロダクトマネージャーがやってきて言いました。「ユーザー登録 API を追加できる?」私はプロジェクトの pages/api フォルダを開き、いつもの手順で書こうとしましたが、フォルダは空でした。そこで思い出しました。これは App Router を使った新しいプロジェクトで、API の書き方が完全に変わっていたのです。
Next.js のドキュメントを開き、「Route Handlers」という言葉を見て、また新しい概念かと身構えました。午後一杯かけてドキュメントを読み、サンプルを追いながら、ようやく route.ts とは何か、なぜ慣れ親しんだ req と res が使えなくなったのかを理解しました。
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 の req と res オブジェクトを使います。
// 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 標準の Request と Response API を使います。
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello from Route Handlers!' })
}
初めてこの書き方を見たとき、私も違和感を覚えました。「なぜ req と res が使えないの?」と。変更には明確な理由があります。
- Web 標準への準拠:ブラウザネイティブの
RequestとResponseAPI を使うことで、コードの汎用性が高まり、現代の Web 開発の流れに沿います - より良い型安全性:TypeScript のサポートが強化され、追加の型定義が不要になります
- Edge Runtime のサポート:Vercel Edge や Cloudflare Workers などのエッジ環境にデプロイでき、応答速度が向上します
核心的な違いの比較
| 特性 | Pages Router | App Router |
|---|---|---|
| ファイル位置 | pages/api/* | app/*/route.ts |
| API 設計 | Node.js req/res | Web 標準 Request/Response |
| HTTP メソッド | デフォルトエクスポートで req.method を分岐 | 各メソッドを個別エクスポート(GET、POST など) |
| キャッシュ動作 | キャッシュしない | GET リクエストはデフォルトでキャッシュ |
正直、最初は変更の理由がよくわかりませんでした。しばらく使ってみると、新しい書き方の方が明確だと気づきました。特に異なる HTTP メソッドを処理するとき、if (req.method === 'GET') のような分岐を書く必要がなくなります。
Route Handlers 実戦:HTTP リクエストの作成と処理
サポートされる HTTP メソッド
Route Handlers は 7 つの HTTP メソッドをサポートします:GET、POST、PUT、PATCH、DELETE、HEAD、OPTIONS。各メソッドは名前付きエクスポートに対応しており、この設計は好きです。コード構造を見るだけで、その 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 は、リクエストデータを取得する方法がいくつかあります。最初は混乱しましたが、整理すると次のとおりです。
- URL パラメータ:
request.urlとURLオブジェクトを使う
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
- リクエストボディ(JSON):
await request.json()を使う
const body = await request.json()
console.log(body.email) // メールフィールドを取得
- リクエストボディ(FormData):
await request.formData()を使う
const formData = await request.formData()
const file = formData.get('avatar')
- ヘッダーと Cookie:
next/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')
// ...
}
- 動的ルートパラメータ:関数の第 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 種類に分けられると気づきました。
- 予想されるエラー:パラメータ形式の誤り、リソース不存在、権限不足——正常なビジネスロジックで、4xx ステータスコードを返すべき
- 予期せぬエラー:データベース接続失敗、外部サービス障害、コードのバグ——システムレベルのエラーで、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 は理解しやすくなります。
核心は次のとおりです。
-
HTTP メソッドで操作を表現:
- GET:リソースの取得
- POST:リソースの作成
- PUT/PATCH:リソースの更新
- DELETE:リソースの削除
-
URL でリソースを表現:
/api/users— ユーザーリスト/api/users/123— ID が 123 のユーザー/api/users/123/posts— そのユーザーの投稿
-
ステータスコードで結果を表現:
- 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 になる——この問題は何度も経験しました。原因は通常次のとおりです。
- ファイル名の誤り:
route.tsまたはroute.jsである必要があり、api.tsなどは不可 - 配置の誤り:
route.tsをpage.tsxと同じフォルダに置けない - 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.js で app ディレクトリが除外されていないか確認してください。
// 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 が必要な場面:
- 外部呼び出し:モバイルアプリや外部サービス向け API
- Webhook:外部サービスからのコールバック受信
- クライアントコンポーネントからのデータ変更:フォーム送信、削除操作など
- 複雑なビジネスロジック:ファイルアップロード、複数の外部 API 呼び出し
Route Handlers が不要な場面:
- Server Components でのデータ取得:直接データベースを照会する方が速い
- シンプルなフォーム送信:Server Actions の方が簡単
- 内部ページ遷移: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 の利点は、コードがユーザーに最も近いエッジノードにデプロイされ、応答が速いことです。制限もあります。
- Node.js API が使えない:
fs、pathなどのモジュールは不可 - 従来型データベースに接続できない:Prisma Data Proxy、PlanetScale など HTTP 接続対応 DB が必要
- パッケージサイズ制限:コードが大きすぎるとデプロイできない
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 へ大きく変わり、最初は戸惑うかもしれません。しばらく使うと、型安全性、コードの明確さ、デプロイの柔軟性など、新しい書き方のメリットが実感できるはずです。
次にできること:
- すぐに試す:既存プロジェクトの API を 1 つ Route Handlers で書き直し、新旧の違いを体感する
- テンプレートを整える:この記事のエラー処理とレスポンス形式のコードをベースに、自分用の API テンプレートを作る
- 継続的に学ぶ:Next.js は急速に進化しているので、公式ドキュメントを追い、Server Actions や Middleware などの新機能も把握する
API 設計に銀の弾丸はありません。この記事の方案が唯一の正解ではありません。プロジェクトの実情に合わせて柔軟に調整し、チームに最適な方法を見つけることが大切です。
この記事が役に立ったら、ブックマークしておいてください。困ったときに見返せます。Next.js 開発がうまくいくことを祈っています!
FAQ
Route Handlers と Pages Router API Routes の主な違いは何ですか?
GET リクエストが最新のデータを返さないのはなぜですか?
いつ Route Handlers を使い、いつ Server Components を使うべきですか?
API エラーをどうすればきれいに処理できますか?
デプロイ後に API がすべて 404 になるのに、ローカルでは正常な場合はどうすればいいですか?
5分で読めます · 公開日: 2026年1月5日 · 更新日: 2026年6月8日
Next.js 完全ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Next.js 国際化完全ガイド:next-intl ベストプラクティス
Next.js App Router 環境における国際化(i18n)の完全なソリューション。next-intl のセットアップから、多言語ルーティングの設計、翻訳ファイルの管理、そしてタイプセーフな実装まで、実戦的なコード例と共に解説します。
第 16 / 47 記事
次の記事
Next.js API パフォーマンス最適化完全ガイド:キャッシュ戦略、ストリーミング、エッジコンピューティング実践
Next.js API の応答を 3 秒から 500ms に短縮するには?キャッシュ戦略の選び方、ストリーミングレスポンスの実装、Edge Functions の活用という 3 つの核心技術を、実コード例と性能比較データ付きで解説します。
第 18 / 47 記事
関連記事
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js Middleware 実践ガイド:パスマッチ、Edge Runtime 制限とよくある落とし穴
コメント
GitHubアカウントでログインしてコメントできます