npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

next-safe-handler

v1.0.2

Published

Type-safe route handler builder for Next.js App Router with composable middleware, validation, and error handling

Readme

next-safe-handler

Type-safe route handler builder for Next.js App Router. Composable middleware, validation, and automatic error handling — zero boilerplate.

License: MIT Built by Claude Code

The Problem

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 }
    );
  }
}

The Solution

// 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.

Install

npm install next-safe-handler

Requirements: Next.js 14+ and a schema library (Zod, Valibot, or ArkType).

Quick Start

// 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 & Middleware

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 Features

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.

Input Validation

Validate request body, query parameters, or route params with any schema library supporting Standard Schema (Zod 3.24+, Valibot, ArkType):

Body Validation (POST/PUT/PATCH)

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 };
  });

Query Validation (GET)

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' }.

Route Params

// 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.

Combining Input + Params

// 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 };
  });

Output Validation

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 };
  });

Error Handling

Throwing Errors

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' },
]);

Error Response Format

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.

Custom Error Handler

const router = createRouter({
  onError: (error, req) => {
    Sentry.captureException(error);
    return Response.json(
      { error: { message: 'Something went wrong' } },
      { status: 500 }
    );
  },
});

Auth Integration Examples

NextAuth / Auth.js

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 });
});

Clerk

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 });
});

Custom JWT

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 });
});

API Reference

createRouter(config?)

Creates a new router instance.

const router = createRouter({
  onError?: (error: unknown, req: Request) => Response | Promise<Response>;
});

router.use(middleware)

Adds middleware. Returns a new (immutable) router.

router.input(schema, options?)

Validates request body or query params. options.source can be 'body' or 'query'.

router.params(schema)

Validates route parameters.

router.output(schema)

Validates handler output (API contract enforcement).

router.handler(fn)

Terminal method — returns a Next.js route handler function.

router.handler(async ({ input, params, ctx, req }) => {
  return { data: '...' }; // Automatically wrapped in Response.json()
});

HttpError

new HttpError(status: number, message: string, code?: string, details?: unknown)

Comparison

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

Attribution

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.

License

MIT