Typed helpers for Next.js route handlers with:
- request context access (
getRequest()), - Zod payload validation (
payload()), - serializable errors that round-trip from backend to frontend.
pnpm add @ideative/next-handlerPeer dependencies:
next>= 15zod>= 4.3.6react>= 19.2.4 (for intl context utilities)next-intl>= 4.8.2 (for intl utilities)
import { NextResponse } from "next/server";
import { withApiHandler, payload, getRequest } from "@ideative/next-handler";
import { z } from "zod";
const schema = z.object({ name: z.string(), email: z.string().email() });
export const POST = withApiHandler(async () => {
const body = await payload(schema);
const req = getRequest();
return NextResponse.json({ from: req.url, ...body });
});import {
withApiHandler,
BadRequestError,
NotFoundError,
UnauthorizedError,
} from "@ideative/next-handler";
export const GET = withApiHandler(async (req) => {
if (!req.headers.get("authorization"))
throw new UnauthorizedError("Missing token");
throw new NotFoundError("User");
});import { NextResponse } from "next/server";
import { withApiHandler, UnauthorizedError } from "@ideative/next-handler";
const authApiHandler = withApiHandler.enhance(async (req) => {
const token = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
if (!token) throw new UnauthorizedError("Missing bearer token");
const user = { id: "u_123", role: "admin" } as const;
return { user };
});
export const GET = authApiHandler(async (_req, context) => {
const { user } = authApiHandler.context();
return NextResponse.json({
userId: user.id,
role: user.role,
sameUserFromContext: context?.user.id,
});
});Built-ins:
BadRequestError(400)UnauthorizedError(401)ForbiddenError(403)NotFoundError(404)ConflictError(409)InternalServerError(500)
Known API errors are returned as a serialized object:
type SerializedApiError = {
name: string;
uid: string;
message: string;
status?: number;
details?: unknown;
isSerializableError: true;
[key: string]: unknown;
};Unhandled non-library errors return:
{ "error": "An error occurred" }import ky from "ky";
import {
scanResponseAndThrowErrors,
BadRequestError,
NotFoundError,
} from "@ideative/next-handler";
const api = ky.create({
prefixUrl: "/api",
hooks: {
afterResponse: [
async (_req, _opts, response) => {
await scanResponseAndThrowErrors(response);
if (!response.ok) throw new Error(response.statusText);
return response;
},
],
},
});
try {
await api.get("users/123").json();
} catch (e) {
if (e instanceof NotFoundError) console.log(e.resource);
if (e instanceof BadRequestError) console.log(e.details);
}If you already have a Response, you can also call:
import { scanResponseAndThrowErrors } from "@ideative/next-handler";
await scanResponseAndThrowErrors(response);import axios from "axios";
import { deserializeApiError } from "@ideative/next-handler";
const api = axios.create({ baseURL: "/api" });
api.interceptors.response.use(
(res) => res,
(err) => {
if (!axios.isAxiosError(err) || !err.response) return Promise.reject(err);
const data = err.response.data;
const candidate =
typeof data === "object" && data !== null && "error" in data
? (data as { error: unknown }).error
: data;
const apiError = deserializeApiError(candidate);
return Promise.reject(apiError ?? err);
}
);EndpointError is abstract and expects (name, status, message, details?).
import {
apiErrorFactory,
EndpointError,
type ErrorDeserializer,
} from "@ideative/next-handler";
class PaymentRequiredError extends EndpointError {
static ErrorName() {
return "PaymentRequiredError";
}
constructor(message = "Payment required") {
super(PaymentRequiredError.ErrorName(), 402, message);
}
}
const deserialize: ErrorDeserializer<PaymentRequiredError> = (d) =>
new PaymentRequiredError(d.message);
apiErrorFactory.register(PaymentRequiredError, deserialize);Register custom errors in both server and client runtime initialization so deserialization works everywhere.
Import intl helpers from:
@ideative/next-handler/intl@ideative/next-handler/intl/intl-context
| Export | Description |
|---|---|
withApiHandler(handler) |
Wraps route handlers and converts thrown EndpointError values to JSON responses. |
withApiHandler.enhance(enhancer) |
Returns an enhanced handler with .context() for request-scoped async ALS context. |
payload(schema) |
Reads and validates request JSON with Zod, throws BadRequestError on invalid input. |
getRequest() |
Gets current NextRequest from AsyncLocalStorage context. |
serializeApiError(error) |
Converts SerializableError to transport-safe payload. |
deserializeApiError(data) |
Converts payload back to typed error, or null if payload is not recognized. |
isSerializedApiError(data) |
Runtime type-guard for serialized payload shape. |
scanResponseAndThrowErrors(response) |
Scans non-OK responses and rethrows serialized API errors if present. |
apiErrorFactory.register(ctor, deserialize) |
Register custom error classes for round-trip behavior. |
- Latest measured coverage: statements
94.81%, branches90.51%, functions96.05%, lines94.81%. - Generate coverage locally with:
pnpm dlx c8 --reporter=text-summary --reporter=text ava --node-arguments='--import=tsx'- Live docs: https://acominotto.github.io/next-handler/
- Repository: https://github.com/acominotto/next-handler
pnpm run buildemits all exported entry points.pnpm testpasses.- Coverage is checked (
c8) and badges are up to date. - README examples match current runtime contracts.
package.jsonexports resolve to emitteddist/files.