A CRUD API that returns errors inconsistently is miserable to consume. Standardize the shape and centralize the logic.
1{
2 "error": {
3 "code": "VALIDATION_FAILED",
4 "message": "Title is required",
5 "details": { "title": ["Title is required"] }
6 }
7}Every error, everywhere, looks like this. Clients write one error handler instead of ten.
1export class ApiError extends Error {
2 constructor(public status: number, public code: string, message: string) {
3 super(message);
4 }
5}
6
7export function handle(e: unknown) {
8 if (e instanceof ApiError) {
9 return NextResponse.json({ error: { code: e.code, message: e.message } }, { status: e.status });
10 }
11 console.error(e); // log the real thing server-side
12 return NextResponse.json(
13 { error: { code: "INTERNAL", message: "Something went wrong" } },
14 { status: 500 },
15 );
16}1// ❌ leaks stack traces, table names, secrets
2return NextResponse.json({ error: e.stack }, { status: 500 });In production, log the full error server-side and return a generic message with maybe a correlation id the user can quote to support.
| Failure | Status |
|---|---|
| Validation | 400 |
| Not authenticated | 401 |
| Not allowed | 403 |
| Missing row (Prisma P2025) | 404 |
| Unique violation (P2002) | 409 |
| Everything unexpected | 500 |
Validate and authorize at the top of the handler and return early. A handler that's a flat sequence of guard clauses followed by the happy path is far easier to read — and to secure — than deeply nested if/else.