Reference: #105 - Standard JSON Response & Error Format
All API routes in this project return a consistent JSON envelope so that the frontend and any consumers can handle responses uniformly.
{
"ok": true,
"data": { ... },
"meta": { "total": 42, "page": 1 }
}| Field | Type | Always present | Description |
|---|---|---|---|
ok |
true |
✅ | Discriminant — always true on success |
data |
T |
✅ | The response payload |
meta |
Record<string, unknown> |
❌ | Optional pagination / extra context |
{
"ok": false,
"error": {
"code": "NOT_FOUND",
"message": "Commitment not found.",
"details": { ... }
}
}| Field | Type | Always present | Description |
|---|---|---|---|
ok |
false |
✅ | Discriminant — always false on error |
error.code |
string |
✅ | Short machine-readable code (see table below) |
error.message |
string |
✅ | Human-readable message safe for UI display |
error.details |
unknown |
❌ | Extra context (never expose sensitive data here) |
| Code | HTTP status | Error class |
|---|---|---|
BAD_REQUEST |
400 | BadRequestError |
VALIDATION_ERROR |
400 | ValidationError |
UNAUTHORIZED |
401 | UnauthorizedError |
FORBIDDEN |
403 | ForbiddenError |
NOT_FOUND |
404 | NotFoundError |
CONFLICT |
409 | ConflictError |
TOO_MANY_REQUESTS |
429 | TooManyRequestsError |
INTERNAL_ERROR |
500 | InternalError |
When a request fails Zod validation, the error response uses a canonical
fieldErrors shape inside error.details so the UI can render field-level
messages consistently:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request data.",
"details": {
"fieldErrors": [
{ "field": "user.profile.name", "message": "Expected string, received number" },
{ "field": "items[0].qty", "message": "Number must be greater than 0" },
{ "field": "", "message": "Password and confirmation do not match" }
]
}
}
}Rules for field:
- Dot notation for nested object keys (
user.profile.name). - Bracket notation for array indices (
items[0].qty,matrix[0][1]). - An empty string (
"") denotes a root-level issue (for example a refinement applied to the whole body). (field, message)pairs are deduplicated so the same message is never rendered twice for the same field.
In any route wrapped with withApiHandler, convert the ZodError using
validationErrorFromZod:
import { withApiHandler } from '@/lib/backend/withApiHandler';
import { validationErrorFromZod } from '@/lib/backend/validationErrors';
const BodySchema = z.object({ /* ... */ });
export const POST = withApiHandler(async (req) => {
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
throw validationErrorFromZod(parsed.error);
}
// ... happy path
});mapZodErrorToFieldErrors(error) is available if you need the
FieldError[] list on its own (for logging, composition with other
details, etc.).
import { ok } from '@/lib/backend/apiResponse';
// Simple success
return ok({ status: 'healthy' });
// → { ok: true, data: { status: 'healthy' } }
// With meta (e.g. pagination)
return ok(items, { total: 100, page: 2, pageSize: 20 });
// → { ok: true, data: [...], meta: { total: 100, page: 2, pageSize: 20 } }import { fail } from '@/lib/backend/apiResponse';
return fail('NOT_FOUND', 'Commitment not found.', undefined, 404);
// → { ok: false, error: { code: 'NOT_FOUND', message: 'Commitment not found.' } }Throw a typed error inside any route wrapped with withApiHandler — it will be caught and converted into the correct error response automatically.
import { withApiHandler } from '@/lib/backend/withApiHandler';
import { ok } from '@/lib/backend/apiResponse';
import { NotFoundError, ValidationError } from '@/lib/backend/errors';
export const GET = withApiHandler(async (req) => {
const commitment = await findCommitment(id);
if (!commitment) {
throw new NotFoundError('Commitment');
// → 404: { ok: false, error: { code: 'NOT_FOUND', message: 'Commitment not found.' } }
}
return ok(commitment);
});Available error classes (all from @/lib/backend/errors):
| Class | Default status |
|---|---|
BadRequestError |
400 |
ValidationError |
400 |
UnauthorizedError |
401 |
ForbiddenError |
403 |
NotFoundError |
404 |
ConflictError |
409 |
TooManyRequestsError |
429 |
InternalError |
500 |
// src/app/api/health/route.ts
import { withApiHandler } from '@/lib/backend/withApiHandler';
import { ok } from '@/lib/backend/apiResponse';
export const GET = withApiHandler(async () => {
return ok({ status: 'healthy' });
// GET /api/health → 200 { ok: true, data: { status: 'healthy' } }
});A shared client-side fetch wrapper (src/lib/apiClient.ts) is available to make API requests with built-in:
- Request Timeout: Aborts automatically after a timeout (default 5000ms).
- Consistent Envelope Parsing: Automatically unwraps
{ ok: true, data: T }envelopes or parses standard error envelopes. - Typed Errors: Throws a subclass of
ErrorcalledApiErrorcontaining the API-returned error code, message, and details.
Use convenience methods like apiGet, apiPost, apiPut, or apiDelete:
import { apiGet, ApiError } from '@/lib/apiClient';
interface Commitment {
id: string;
status: string;
}
async function loadData() {
try {
const commitments = await apiGet<Commitment[]>('/api/commitments');
console.log(commitments);
} catch (err) {
if (err instanceof ApiError) {
console.error(`API Error [${err.code}]: ${err.message}`);
} else {
console.error('Network or other error:', err);
}
}
}You can use the generic apiFetch for other methods or custom configurations:
import { apiFetch } from '@/lib/apiClient';
const data = await apiFetch<MyResponseType>(
'/api/custom-endpoint',
{
method: 'PATCH',
headers: { 'X-Custom-Header': 'value' },
},
10000 // custom 10s timeout
);| File | Purpose |
|---|---|
src/lib/apiClient.ts |
Client-side API fetch client with timeout & typed errors |
src/lib/backend/apiResponse.ts |
ok() and fail() response helpers |
src/lib/backend/errors.ts |
Typed error classes with HTTP status codes |
src/lib/backend/withApiHandler.ts |
HOF that catches ApiError and calls fail() |
docs/api-response-format.md |
This document |
Created as part of issue #105. Update this document when new error codes are introduced.