@ideative/next-handler
v0.1.0
Published
Next Handler - Simple Next.js handler for API routes
Maintainers
Readme
@ideative/next-handler
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.
Install
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)
Backend usage
Wrap handlers
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 });
});Throw typed API errors
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");
});Add request-scoped async context
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)
Wire format contract
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" }Frontend integration with ky (afterResponse)
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);Frontend integration with axios (response interceptor)
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);
}
);Custom error types
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.
Intl exports
Import intl helpers from:
@ideative/next-handler/intl@ideative/next-handler/intl/intl-context
API summary
| 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. |
Coverage
- 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
v0.1.0 release checklist
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.
