Type-safe route handler builder for Next.js App Router. Composable middleware, validation, and automatic error handling — zero boilerplate.
Every Next.js App Router route handler requires the same 30-40 lines of boilerplate:
// app/api/users/route.ts — WITHOUT next-safe-handler
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten() },
{ status: 400 }
);
}
const user = await db.user.create({ data: parsed.data });
return NextResponse.json({ user }, { status: 201 });
} catch (e) {
console.error(e);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}// app/api/users/route.ts — WITH next-safe-handler
export const POST = adminRouter
.input(z.object({ name: z.string().min(1), email: z.string().email() }))
.handler(async ({ input, ctx }) => {
const user = await db.user.create({ data: input });
return { user };
});8 lines instead of 30. Full type safety. Automatic error handling. Composable auth.
npm install next-safe-handlerRequirements: Next.js 14+ and a schema library (Zod, Valibot, or ArkType).
// lib/api.ts
import { createRouter, HttpError } from 'next-safe-handler';
export const router = createRouter();// app/api/hello/route.ts
import { router } from '@/lib/api';
export const GET = router.handler(async () => {
return { message: 'Hello, world!' };
});That's it. The handler returns JSON with proper status codes and catches all errors automatically.
Routers are composable and reusable. Each .use() adds middleware and returns a new router:
// lib/api.ts
import { createRouter, HttpError } from 'next-safe-handler';
import { getServerSession } from 'next-auth';
import { authOptions } from './auth';
// Base router
export const router = createRouter();
// Authenticated router — adds user to context
export const authedRouter = router.use(async ({ next }) => {
const session = await getServerSession(authOptions);
if (!session?.user) throw new HttpError(401, 'Authentication required');
return next({ user: session.user });
});
// Admin router — requires admin role
export const adminRouter = authedRouter.use(async ({ ctx, next }) => {
if (ctx.user.role !== 'ADMIN') throw new HttpError(403, 'Admin access required');
return next();
});Use different routers for different access levels:
// Public endpoint
export const GET = router.handler(async () => ({ status: 'ok' }));
// Authenticated endpoint
export const GET = authedRouter.handler(async ({ ctx }) => ({ user: ctx.user }));
// Admin-only endpoint
export const GET = adminRouter.handler(async ({ ctx }) => ({ admin: ctx.user.name }));Middleware uses the onion pattern — each middleware wraps the next:
// Timing middleware
const timedRouter = router.use(async ({ req, next }) => {
const start = Date.now();
const response = await next();
console.log(`${req.method} ${req.url} - ${Date.now() - start}ms`);
return response;
});Context accumulates through the chain. Each next({ key: value }) merges into the context, and TypeScript tracks the types.
Validate request body, query parameters, or route params with any schema library supporting Standard Schema (Zod 3.24+, Valibot, ArkType):
export const POST = authedRouter
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.handler(async ({ input }) => {
// input is typed as { name: string; email: string }
const user = await db.user.create({ data: input });
return { user };
});export const GET = router
.input(z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
}))
.handler(async ({ input }) => {
// input.page is number (coerced from string)
const users = await db.user.findMany({
skip: (input.page - 1) * input.limit,
take: input.limit,
});
return { users, page: input.page };
});Auto-detection: GET/HEAD/DELETE reads from query params, POST/PUT/PATCH reads from body. Override with { source: 'query' } or { source: 'body' }.
// app/api/users/[id]/route.ts
export const GET = authedRouter
.params(z.object({ id: z.string().uuid() }))
.handler(async ({ params }) => {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) throw new HttpError(404, 'User not found');
return { user };
});Works with both Next.js 14 (direct params) and Next.js 15+ (Promise params) automatically.
// app/api/posts/[id]/route.ts
export const PUT = adminRouter
.params(z.object({ id: z.string() }))
.input(z.object({ title: z.string(), content: z.string() }))
.handler(async ({ input, params, ctx }) => {
const post = await db.post.update({
where: { id: params.id },
data: { ...input, updatedBy: ctx.user.id },
});
return { post };
});Enforce API contracts by validating handler output:
export const GET = router
.output(z.object({
users: z.array(z.object({ id: z.string(), name: z.string() })),
total: z.number(),
}))
.handler(async () => {
return { users: [...], total: 42 };
});Throw HttpError anywhere in middleware or handlers:
import { HttpError } from 'next-safe-handler';
throw new HttpError(404, 'User not found');
throw new HttpError(403, 'Forbidden', 'INSUFFICIENT_PERMISSIONS');
throw new HttpError(422, 'Invalid', 'VALIDATION_ERROR', [
{ path: 'email', message: 'Already taken' },
]);All errors follow a consistent shape:
{
"error": {
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"status": 400,
"details": [
{ "path": "email", "message": "Invalid email" }
]
}
}| Error Type | Status | Code |
|---|---|---|
| Validation error | 400 | VALIDATION_ERROR |
| Malformed JSON | 400 | BAD_REQUEST |
HttpError(401) |
401 | UNAUTHORIZED |
HttpError(403) |
403 | FORBIDDEN |
HttpError(404) |
404 | NOT_FOUND |
| Unknown error | 500 | INTERNAL_SERVER_ERROR |
Security: Unknown errors never leak messages in production.
const router = createRouter({
onError: (error, req) => {
Sentry.captureException(error);
return Response.json(
{ error: { message: 'Something went wrong' } },
{ status: 500 }
);
},
});export const authedRouter = router.use(async ({ next }) => {
const session = await getServerSession(authOptions);
if (!session?.user) throw new HttpError(401, 'Not authenticated');
return next({ user: session.user });
});import { auth } from '@clerk/nextjs/server';
export const authedRouter = router.use(async ({ next }) => {
const { userId } = await auth();
if (!userId) throw new HttpError(401, 'Not authenticated');
return next({ userId });
});import { jwtVerify } from 'jose';
export const authedRouter = router.use(async ({ req, next }) => {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (!token) throw new HttpError(401, 'Missing token');
const { payload } = await jwtVerify(token, secret);
return next({ user: payload });
});Creates a new router instance.
const router = createRouter({
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
});Adds middleware. Returns a new (immutable) router.
Validates request body or query params. options.source can be 'body' or 'query'.
Validates route parameters.
Validates handler output (API contract enforcement).
Terminal method — returns a Next.js route handler function.
router.handler(async ({ input, params, ctx, req }) => {
return { data: '...' }; // Automatically wrapped in Response.json()
});new HttpError(status: number, message: string, code?: string, details?: unknown)| Feature | Raw handlers | tRPC | next-safe-handler |
|---|---|---|---|
| REST-native | Yes | No (RPC) | Yes |
| Type-safe input | Manual | Yes | Yes |
| Type-safe output | No | Yes | Yes |
| Middleware chain | No | Yes | Yes |
| Auth composable | No | Yes | Yes |
| Error handling | Manual | Built-in | Built-in |
| Learning curve | Low | High | Low |
| Incremental adoption | N/A | Hard | Easy |
This project was entirely designed, researched, written, tested, and published by Claude Code (Anthropic's AI coding agent). From market research identifying the gap in the Next.js ecosystem, to API design, implementation, test suite, documentation, and build configuration -- every line was authored by Claude.
MIT