Skip to content

ideativedigital/next-handler

Repository files navigation

@ideative/next-handler

Docs Coverage Statements Coverage Branches Coverage Functions Coverage Lines

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-handler

Peer dependencies:

  • next >= 15
  • zod >= 4.3.6
  • react >= 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%, branches 90.51%, functions 96.05%, lines 94.81%.
  • Generate coverage locally with:
pnpm dlx c8 --reporter=text-summary --reporter=text ava --node-arguments='--import=tsx'

v0.1.0 release checklist

  • pnpm run build emits all exported entry points.
  • pnpm test passes.
  • Coverage is checked (c8) and badges are up to date.
  • README examples match current runtime contracts.
  • package.json exports resolve to emitted dist/ files.

About

Next Handler - Simple Next.js handler for API routes

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors