概要
Route Handler 内のエラー処理をラッパー関数と型付きカスタムエラーで統一する。ハンドラごとに try/catch を書き直す代わりに、共通の withErrorHandler でラップし、400 / 404 / 500 を一貫したレスポンス形式で返す。
インストール
# 追加インストールは不要
実装
エラー型とレスポンス型の定義
// lib/api-error.ts
export type ErrorCode = "BAD_REQUEST" | "NOT_FOUND" | "INTERNAL_ERROR";
export type ErrorResponse = {
error: {
code: ErrorCode;
message: string;
};
};
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: ErrorCode,
message: string
) {
super(message);
this.name = "ApiError";
}
static badRequest(message: string) {
return new ApiError(400, "BAD_REQUEST", message);
}
static notFound(message: string) {
return new ApiError(404, "NOT_FOUND", message);
}
static internal(message = "Internal server error") {
return new ApiError(500, "INTERNAL_ERROR", message);
}
}
エラーハンドララッパー
// lib/with-error-handler.ts
import { NextResponse } from "next/server";
import { ApiError, ErrorResponse } from "./api-error";
type RouteHandler = (request: Request, context: unknown) => Promise<Response>;
export function withErrorHandler(handler: RouteHandler): RouteHandler {
return async (request, context) => {
try {
return await handler(request, context);
} catch (err) {
if (err instanceof ApiError) {
return NextResponse.json<ErrorResponse>(
{ error: { code: err.code, message: err.message } },
{ status: err.status }
);
}
console.error(err);
return NextResponse.json<ErrorResponse>(
{ error: { code: "INTERNAL_ERROR", message: "Internal server error" } },
{ status: 500 }
);
}
};
}
Route Handler での使用例
// app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
import { ApiError } from "@/lib/api-error";
import { withErrorHandler } from "@/lib/with-error-handler";
const users: Record<string, { id: string; name: string }> = {
"1": { id: "1", name: "山田太郎" },
};
export const GET = withErrorHandler(async (_request, context) => {
const { id } = (context as { params: { id: string } }).params;
if (!id || typeof id !== "string") {
throw ApiError.badRequest("id is required");
}
const user = users[id];
if (!user) {
throw ApiError.notFound(`User ${id} not found`);
}
return NextResponse.json(user);
});
入力バリデーションを含む POST の例
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { ApiError } from "@/lib/api-error";
import { withErrorHandler } from "@/lib/with-error-handler";
type CreateUserInput = { name: string; email: string };
function validateCreateUser(body: unknown): CreateUserInput {
if (
typeof body !== "object" ||
body === null ||
typeof (body as Record<string, unknown>).name !== "string" ||
typeof (body as Record<string, unknown>).email !== "string"
) {
throw ApiError.badRequest("name and email are required");
}
return body as CreateUserInput;
}
export const POST = withErrorHandler(async (request) => {
let body: unknown;
try {
body = await request.json();
} catch {
throw ApiError.badRequest("Invalid JSON body");
}
const input = validateCreateUser(body);
// 実際の保存処理(省略)
const created = { id: crypto.randomUUID(), ...input };
return NextResponse.json(created, { status: 201 });
});
レスポンス例
// 400 Bad Request
{
"error": {
"code": "BAD_REQUEST",
"message": "name and email are required"
}
}
// 404 Not Found
{
"error": {
"code": "NOT_FOUND",
"message": "User 999 not found"
}
}
// 500 Internal Server Error
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}
ポイント
ApiErrorにステータスコードとErrorCodeを持たせることで、ハンドラ内ではthrow ApiError.notFound(...)とだけ書けば済むwithErrorHandlerがApiError以外の予期しないエラーも500に変換するため、未ハンドルエラーが素のスタックトレースでクライアントに漏れるのを防ぐErrorResponse型を共通定義しておくと、クライアント側でもレスポンスの型をimportして使えて型安全になるnextjs-route-handler-crudとの使い分け: ハッピーパスの CRUD はnextjs-route-handler-crud、エラーレスポンス設計を整備したいときにこのパターンを組み合わせる- Zod でバリデーションする場合は
zod-api-response-validationと組み合わせるとZodErrorをApiError.badRequestに変換できる