REST API Conventions
Quick start
Every API route follows this structure:
typescript
import { z } from "zod";
import { apiHandler, ApiError } from "@/lib/api";
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export const POST = apiHandler(async (req) => {
const body = schema.parse(await req.json());
const result = await createUser(body);
return { data: result, status: 201 };
});
Response format
All endpoints return this shape:
typescript
// Success
{ "data": <result>, "meta"?: { "page": 1, "total": 100 } }
// Error
{ "error": { "code": "VALIDATION_ERROR", "message": "..." } }
Validation
Validate all inputs with Zod at the top of each handler. Use .parse() (throws on failure) — the apiHandler wrapper catches Zod errors automatically.
For query params:
typescript
const query = z.object({ page: z.coerce.number().default(1) });
const { page } = query.parse(Object.fromEntries(url.searchParams));
Error handling
Throw ApiError for business logic errors:
typescript
throw new ApiError("NOT_FOUND", "User not found", 404);
throw new ApiError("FORBIDDEN", "Insufficient permissions", 403);
The apiHandler wrapper catches all errors and formats them consistently.
Advanced patterns
For authentication middleware, rate limiting, and pagination helpers, see patterns.md.