Next.js API route helpers: typed handlers, Zod-validated bodies, and serializable errors that round-trip between server and client.
pnpm add next-handler next zodPeer dependencies: next (≥15), zod (≥4.3.6).
Use withApiHandler so the request is in context and thrown errors become JSON responses.
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { withApiHandler, payload, getRequest } from "next-handler";
import { z } from "zod";
const createUserSchema = z.object({ name: z.string(), email: z.string().email() });
export const POST = withApiHandler(async () => {
const body = await payload(createUserSchema);
// body is { name: string; email: string }
const request = getRequest();
// ... create user, then:
return NextResponse.json({ id: "1", ...body });
});Throw BadRequestError, NotFoundError, etc. They are serialized and sent as JSON with the right status.
import {
withApiHandler,
payload,
BadRequestError,
NotFoundError,
UnauthorizedError,
} from "next-handler";
export const GET = withApiHandler(async (req) => {
const auth = req.headers.get("authorization");
if (!auth) throw new UnauthorizedError("Missing token");
// ...
});
export const POST = withApiHandler(async () => {
const body = await payload(someSchema); // throws BadRequestError if invalid
const user = await findUser(body.id);
if (!user) throw new NotFoundError("User");
return NextResponse.json(user);
});Built-in error classes: BadRequestError (400), UnauthorizedError (401), ForbiddenError (403), NotFoundError (404), ConflictError (409), InternalServerError (500). All extend EndpointError and are serialized with name, message, status, and optional details.
When the API returns an error, the response body is the serialized error object (or { error: "An error occurred" } for unhandled errors). Use afterResponse to deserialize and rethrow so callers get BadRequestError, NotFoundError, etc.
import ky from "ky";
import {
deserializeApiError,
BadRequestError,
NotFoundError,
} from "next-handler";
const api = ky.create({
prefixUrl: "/api",
hooks: {
afterResponse: [
async (_request, _options, response) => {
if (response.ok) return response;
let data: unknown;
try {
data = await response.json();
} catch {
return response;
}
const error = deserializeApiError(data);
if (error) throw error;
const fallback = new Error(
typeof data === "object" && data !== null && "error" in data
? String((data as { error: unknown }).error)
: response.statusText
);
(fallback as Error & { status: number }).status = response.status;
throw fallback;
},
],
},
});
// Errors are proper classes
try {
await api.get("users/123").json();
} catch (e) {
if (e instanceof NotFoundError) {
console.log(e.message); // "User not found"
console.log(e.resource); // "User"
console.log(e.status); // 404
}
if (e instanceof BadRequestError) {
console.log(e.details); // e.g. Zod issues
}
}Minimal hook that only rethrows when the body is a serialized API error:
import { deserializeApiError } from "next-handler";
const api = ky.create({
prefixUrl: "/api",
hooks: {
afterResponse: [
async (_request, _options, response) => {
if (response.ok) return response;
try {
const data = await response.json();
const error = deserializeApiError(data);
if (error) throw error;
} catch (e) {
if (e instanceof Error && "status" in e) throw e;
throw e;
}
return response;
},
],
},
});Use a response interceptor to turn error responses into thrown BadRequestError / NotFoundError / etc. when the body is a serialized API error.
import axios from "axios";
import { deserializeApiError, BadRequestError, NotFoundError } from "next-handler";
const api = axios.create({ baseURL: "/api" });
api.interceptors.response.use(
(response) => response,
(error) => {
if (!axios.isAxiosError(error) || !error.response) return Promise.reject(error);
const data = error.response.data;
const apiError = deserializeApiError(data);
if (apiError) return Promise.reject(apiError);
return Promise.reject(error);
}
);
try {
await api.get("/users/123");
} catch (e) {
if (e instanceof NotFoundError) {
console.log(e.message, e.resource, e.status);
}
if (e instanceof BadRequestError) {
console.log(e.details);
}
}With TypeScript, ensure your catch clause can narrow to your error types (e.g. import NotFoundError, BadRequestError from next-handler).
Register a custom error so it serializes and deserializes across the wire:
import {
apiErrorFactory,
EndpointError,
type ErrorDeserializer,
type SerializableErrorCtor,
} from "next-handler";
class PaymentRequiredError extends EndpointError {
static ErrorName() {
return "PaymentRequiredError";
}
constructor(message = "Payment required") {
super(402, message);
this.name = PaymentRequiredError.ErrorName();
}
}
const deserialize: ErrorDeserializer = (d) => new PaymentRequiredError(d.message);
apiErrorFactory.register(PaymentRequiredError as SerializableErrorCtor, deserialize);Do this once (e.g. in a shared module or app setup) on both server and client so responses using PaymentRequiredError round-trip correctly.
| Export | Description |
|---|---|
withApiHandler(handler) |
Wraps a route handler; provides request context and turns thrown EndpointError into JSON responses. |
payload(schema) |
Parses and validates request body with Zod; throws BadRequestError on invalid input. |
getRequest() |
Returns the current NextRequest inside a wrapped handler. |
serializeApiError(error) |
Turns an error into a plain object for JSON (used by the handler). |
deserializeApiError(data) |
Turns a parsed JSON object into an error instance, or null if not a serialized API error. |
apiErrorFactory |
Registry: register(ctor, deserialize) for custom error types. |
BadRequestError, NotFoundError, … |
Built-in error classes. |
Response body for API errors is the serialized error object (e.g. { name, message, status, details?, isSerializableError: true }). Use deserializeApiError(responseBody) in ky/axios interceptors to rethrow the correct class on the client.