A typesafe and structured way to build Next.js App Router API handlers. Get a tRPC-like developer experience with Zod validation and composable middleware, without leaving your standard API routes.
- End-to-End Type Safety: Automatically infer types for route params, search params, and request body.
- Zod-First Validation: Use Zod schemas to validate all inputs before your handler runs.
- Composable Middleware: Chain middleware functions to handle authentication, logging, or any other repeated logic.
- Validation-First Architecture: Middleware runs after validation, so your auth checks can safely access validated data (e.g., check if a user owns the resource from
context.id). - Centralized Error Handling: Throw a simple
ApiErrorfor expected failures and use.failed()to log all unexpected exceptions. - Full Next.js Control: An "escape hatch" lets you return a full
NextResponseat any time to set custom headers, cookies, or cache tags.
Install the package and its peer dependency, zod.
npm install better-next-api zodyarn add better-next-api zodpnpm add better-next-api zodThe first step is to create your API "clients" (handlers) in a single file. This is where you'll define your base handlers and all your middleware.
Create a file at /lib/api-handler.ts:
// /lib/api-handler.ts
import "server-only";
import { createApiHandler, ApiError, HandlerInput } from "better-next-api";
import * as z from "zod"; // Use namespace import for Zod
// --- 1. Define Middleware ---
// (This is just an example, replace with your real auth logic)
// Define your app's user type
type User = {
id: string;
name: string;
isAdmin: boolean;
};
// Mock session function
const getSession = async (): Promise<{ user: User } | null> => {
return {
user: { id: "user_123", name: "Test User", isAdmin: true },
};
};
/**
* A middleware to check for an authenticated user.
*/
export const authMiddleware = async ()T> {
const session = await getSession();
if (!session?.user) {
throw new ApiError({
code: 401,
type: "UNAUTHORIZED",
message: "Not authenticated.",
});
}
// This object is merged into the `ctx` for the next middleware or handler
return {
user: session.user,
};
};
/**
* A middleware to check for admin privileges.
* This MUST run *after* authMiddleware.
*/
export const adminMiddleware = async (
// The input is typesafe! It knows `user` is in the context.
input: HandlerInput<any, any, any, { user: User }>
) => {
if (!input.ctx.user.isAdmin) {
throw new ApiError({
code: 403,
type: "FORBIDDEN",
message: "You are not an admin.",
});
}
// No new context to add, just pass the check
return {};
};
// --- 2. Define Handlers ---
/**
* A public API handler that anyone can access.
*/
export const publicApi = createApiHandler();
/**
* A protected API handler that requires a user to be authenticated.
*/
export const protectedApi = publicApi.use(authMiddleware);
/**
* An admin-only API handler.
*/
export const adminApi = protectedApi.use(adminMiddleware);Now you can import and use your handlers in any API route.
The builder automatically wraps your return value in NextResponse.json() for you.
// /app/api/hello/route.ts
import { publicApi } from "@/lib/api-handler";
export const GET = publicApi.get(async ({}) => {
// `context`, `query`, `body`, and `ctx` are all available
return { message: "Hello, world!" };
});
// Responds with:
// status: 200
// body: { "message": "Hello, world!" }Validate all parts of an incoming request by chaining validation methods.
.context(schema): Validatesparamsfrom dynamic routes (e.g.,[id])..query(schema): ValidatessearchParams(e.g.,?include=true)..body(schema): Validates the JSON body of aPOSTorPATCHrequest.
If validation fails, the builder automatically returns a 400 Bad Request response with the Zod issues.
// /app/api/posts/[id]/route.ts
import { protectedApi } from "@/lib/api-handler";
import { ApiError } from "better-next-api";
import { db } from "@/lib/db";
import * as z from "zod";
// 1. Define schemas
const contextSchema = z.object({
id: z.string().cuid(), // Validates `params.id`
});
const getQuerySchema = z.object({
includeComments: z.coerce.boolean().optional(),
});
const patchBodySchema = z.object({
title: z.string().min(3).optional(),
content: z.string().min(10).optional(),
});
// 2. Use schemas in your handlers
// --- GET Handler ---
export const GET = protectedApi
.context(contextSchema) // 1. Validate `params`
.query(getQuerySchema) // 2. Validate `searchParams`
.get(async ({ context, query, ctx }) => {
// Everything is typesafe!
// - context: { id: string }
// - query: { includeComments?: boolean }
// - ctx: { user: { id: string, ... } }
const post = await db.post.findFirst({
where: {
id: context.id,
authorId: ctx.user.id, // Enforce ownership
},
include: {
comments: query.includeComments,
},
});
if (!post) {
throw new ApiError({ code: 404, message: "Post not found." });
}
return post;
});
// --- PATCH Handler ---
export const PATCH = protectedApi
.context(contextSchema) // 1. Validate `params`
.body(patchBodySchema) // 2. Validate `request.json()`
.patch(async ({ context, body, ctx }) => {
// Typesafe!
// - context: { id: string }
// - body: { title?: string, content?: string }
// - ctx: { user: { id: string, ... } }
const updatedPost = await db.post.update({
where: {
id: context.id,
authorId: ctx.user.id,
},
data: body,
});
return updatedPost;
});A key feature is that validation runs before middleware. This allows you to write powerful authorization middleware that can safely access validated data.
For example, your middleware can check if a user is the owner of a post before the handler logic runs.
// /lib/api-handler.ts
// ... (authMiddleware, publicApi, protectedApi as before) ...
import * as z from "zod";
// 1. Define a schema that your middleware will need
const postContextSchema = z.object({
id: z.string().cuid(),
});
// 2. Define the ownership middleware
export const isPostOwnerMiddleware = async (
// It receives the validated context!
input: HandlerInput<
typeof postContextSchema, // Typesafe: knows `context.id` is a CUID
any,
any,
{ user: User } // Typesafe: knows `user` is in ctx
>
) => {
const { context, ctx } = input;
const post = await db.post.findUnique({
where: { id: context.id },
select: { authorId: true },
});
if (post?.authorId !== ctx.user.id) {
throw new ApiError({ code: 403, message: "You do not own this resource." });
}
return {};
};
// 3. Create a new, specific handler
export const postOwnerApi = protectedApi.use(isPostOwnerMiddleware);// /app/api/posts/[id]/route.ts
// Use your new handler
import { postOwnerApi } from "@/lib/api-handler";
// ... (schemas) ...
// This PATCH handler will only run if:
// 1. `context.id` is a valid CUID
// 2. The user is authenticated (from `protectedApi`)
// 3. The user is the post owner (from `isPostOwnerMiddleware`)
export const PATCH = postOwnerApi
.context(contextSchema)
.body(patchBodySchema)
.patch(async ({ context, body, ctx }) => {
// By the time this code runs, we know the user is the owner.
const updatedPost = await db.post.update({
where: { id: context.id },
data: body,
});
return updatedPost;
});There are two types of errors.
For expected errors (e.g., "Not Found," "Unauthorized"), throw an ApiError. The builder will catch it and send a formatted JSON response with the correct status code.
// In your handler
if (!post) {
throw new ApiError({
code: 404,
type: "NOT_FOUND",
message: "Post not found.",
});
}Your client will receive a standardized error:
// status: 404
{
"message": "Post not found.",
"type": "NOT_FOUND"
}For unexpected errors (e.g., a database crash, a 500 error), use the .failed() method on your base handler. This is the perfect place to log errors to a service like Sentry or LogSnag.
// /lib/api-handler.ts
import { createApiHandler } from "better-next-api";
// import { Sentry } from "@sentry/nextjs";
/**
* A global handler for logging all unexpected API errors.
*/
const globalFailureHandler = async ({
req,
error,
}: {
req: NextRequest;
error: unknown;
}) => {
console.error("--- UNHANDLED API ERROR ---", error);
// Sentry.captureException(error);
};
/**
* A public API handler that includes global error logging.
*/
export const publicApi = createApiHandler()
.failed(globalFailureHandler); // <-- Attach it here
// All other handlers that chain from publicApi will inherit it
export const protectedApi = publicApi.use(authMiddleware);If you need full control over the response to set custom headers, cookies, or cache tags, just return a NextResponse object directly from your handler. The builder will detect it and send it as-is.
// /app/api/posts/route.ts
import { publicApi } from "@/lib/api-handler";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export const GET = publicApi
.get(async () => {
const posts = await db.post.findMany({ take: 10 });
// Return a full NextResponse to set cache headers
return NextResponse.json(posts, {
status: 200,
headers: {
'Cache-Control': 's-maxage=60, stale-while-revalidate',
}
});
});| Method | Description |
|---|---|
.context(schema) |
Adds a Zod schema to validate params. |
.query(schema) |
Adds a Zod schema to validate searchParams. |
.body(schema) |
Adds a Zod schema to validate request.json(). |
.use(middleware) |
Adds an async middleware function to the chain. |
.failed(handler) |
Adds an async callback to run on unhandled errors. |
.get(handler) |
Creates the final GET route handler. |
.post(handler) |
Creates the final POST route handler. |
.put(handler) |
Creates the final PUT route handler. |
.patch(handler) |
Creates the final PATCH route handler. |
.delete(handler) |
Creates the final DELETE route handler. |
Your middleware and final handler functions receive a single, typesafe object with these properties:
context: The validated route parameters.query: The validated search parameters.body: The validated JSON body.ctx: The cumulative context from all middleware (e.g.,{ user: ... }).req: The rawNextRequestobject.
MIT