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

v0.5.0

Published

A minimal, type-safe wrapper for Next.js API routes and Server Actions

Readme

next-server-wrap

A minimal, type-safe wrapper for Next.js API routes and Server Actions. Handles auth, validation, rate limiting, caching, timeout, retry, and error handling with zero boilerplate.

Features

  • Authentication & Authorization - Role-based access control
  • Validation - Zod schema validation for params, query, body
  • Rate Limiting - Atomic, Redis-ready via cache adapter
  • Response Caching - TTL-based with custom key generators
  • Timeout - Configurable request timeouts
  • Retry - Exponential backoff for transient failures
  • Request ID Tracking - Distributed tracing support
  • Audit Logging - Track who did what
  • Response Transformers - Customize response format

Install

npm install next-server-wrap
# or
pnpm add next-server-wrap

Peer dependency: zod >= 3.0.0

Example project: next-server-wrap-example - Full example with NextAuth, Prisma, and server actions/APIs.

Quick Start

1. Create your wrapper instance

// lib/api.ts
import {
  createApiWrapper,
  createActionWrapper,
  defineAuthAdapter,
  defineCacheAdapter,
  defineLoggerAdapter,
  type AuthRequestContext,
} from 'next-server-wrap';

// Define your user type
interface AppUser {
  id: string;
  role: string;
  companyId?: string;
}

// Auth adapter
const authAdapter = defineAuthAdapter<AppUser>({
  async verify(ctx: AuthRequestContext) {
    // ctx.headers - request headers
    // ctx.cookies - parsed cookies object
    const token = ctx.headers.get('authorization')?.split(' ')[1];
    if (!token) return null;
    return { id: '123', role: 'admin' };
  },
  hasRole(user, roles) {
    return roles.length === 0 || roles.includes(user.role);
  },
});

// Cache adapter (handles BOTH caching AND rate limiting)
const cacheAdapter = defineCacheAdapter({
  async get(key) { /* return cached value or null */ },
  async set(key, value, ttlMs) { /* store with TTL */ },
  async delete(key) { /* remove key */ },
  async increment(key, ttlMs) { /* atomic increment for rate limiting */ },
});

// Logger adapter
const loggerAdapter = defineLoggerAdapter<AppUser>({
  debug: (msg, meta) => console.debug(msg, meta),
  info: (msg, meta) => console.info(msg, meta),
  warn: (msg, meta) => console.warn(msg, meta),
  error: (msg, err, meta) => console.error(msg, err, meta),
  audit: (event) => console.info('[AUDIT]', event),
});

export const apiWrapper = createApiWrapper<AppUser>({
  adapters: {
    auth: authAdapter,
    cache: cacheAdapter,
    logger: loggerAdapter,
  },
  defaults: {
    timeout: 30000,
  },
});

export const actionWrapper = createActionWrapper<AppUser>({
  adapters: {
    auth: authAdapter,
    cache: cacheAdapter, // Required for action caching and rate limiting
    logger: loggerAdapter,
  },
  // Required when using auth or tenantScoped in server actions
  async getAuthContext() {
    const { headers, cookies } = await import('next/headers');
    const headersList = await headers();
    const cookieStore = await cookies();

    const cookieObj: Record<string, string> = {};
    cookieStore.getAll().forEach((c) => {
      cookieObj[c.name] = c.value;
    });

    return { headers: headersList, cookies: cookieObj };
  },
});

2. Use in API routes

// app/api/users/[id]/route.ts
import { z } from 'zod';
import { apiWrapper } from '@/lib/api';
import { ApiResponse } from 'next-server-wrap';

const paramsSchema = z.object({ id: z.string().uuid() });

export const GET = apiWrapper(
  async (ctx) => {
    const { id } = ctx.parsedParams;
    const user = await db.user.findUnique({ where: { id } });

    if (!user) throw ApiResponse.notFound('User not found');

    return ApiResponse.success(user);
  },
  {
    auth: [],  // any authenticated user
    validation: { params: paramsSchema },
  }
);

3. Use in Server Actions

Server actions use an envelope pattern - they return { success: true, data } or { success: false, error } instead of throwing errors. This is required because Next.js swallows thrown errors in production.

'use server';
import { z } from 'zod';
import { actionWrapper } from '@/lib/api';
import { ActionResponse } from 'next-server-wrap';

const schema = z.object({ id: z.string(), name: z.string() });

export const updateUser = actionWrapper(
  async (ctx) => {
    const user = await db.user.findUnique({ where: { id: ctx.parsedBody.id } });

    if (!user) {
      throw ActionResponse.notFound('User not found');
    }

    const updated = await db.user.update({
      where: { id: ctx.parsedBody.id },
      data: { name: ctx.parsedBody.name },
    });

    return ActionResponse.success(updated);
  },
  { auth: [], validation: { body: schema }, timeout: 5000 }
);

// With caching
export const getProducts = actionWrapper(
  async (ctx) => {
    const products = await db.product.findMany({ take: ctx.parsedBody.limit });
    return ActionResponse.success(products);
  },
  {
    validation: { body: z.object({ limit: z.number() }) },
    cache: {
      ttlMs: 60000, // 60 seconds
      keyGenerator: (input) => `products:${input.limit}`,
    },
  }
);

Consuming action results:

const result = await updateUser({ id: '123', name: 'John' });

if (result.success) {
  console.log(result.data); // User object
} else {
  console.log(result.error.message); // Error message
  console.log(result.error.code);    // Error code
  console.log(result.error.status);  // HTTP status
  console.log(result.error.errors);  // Validation errors (if any)
}

Configuration Options

API Routes

apiWrapper(handler, {
  // Authentication
  auth: ['admin', 'super'],  // only these roles
  auth: [],                   // any authenticated user
  // auth: undefined          // public (default)

  // Validation (Zod schemas)
  validation: {
    params: z.object({ id: z.string().uuid() }),
    query: z.object({ page: z.coerce.number() }),
    body: z.object({ name: z.string() }),
  },

  // Rate limiting (uses cache.increment)
  rateLimit: { max: 100, windowMs: 60000 },
  rateLimit: false,  // disable

  // Response caching (GET only)
  cache: {
    ttlMs: 60000,
    keyGenerator: (req) => `custom:${req.url}`,
    successOnly: true,  // only cache 2xx (default)
  },

  // Timeout
  timeout: 5000,  // ms

  // Retry with exponential backoff
  retry: {
    attempts: 3,
    delayMs: 100,
    retryOn: [502, 503, 504],
    shouldRetry: (error, attempt) => true,
  },

  // Multi-tenant (requires auth.isTenantValid)
  tenantScoped: true,

  // Audit logging
  audit: true,

  // Custom middleware
  middleware: [myMiddleware],
});

Server Actions

actionWrapper(handler, {
  // Same options as API routes, plus:
  auth: [],
  validation: { body: schema },
  rateLimit: { max: 10, windowMs: 60000 },
  timeout: 5000,
  retry: { attempts: 3, delayMs: 100 },
  tenantScoped: true,
  audit: true,

  // Action caching (caches by input)
  cache: {
    ttlMs: 60000,
    keyGenerator: (input) => `key:${input.id}`, // optional
  },
});

Context Object

async (ctx) => {
  ctx.req;           // Request object
  ctx.requestId;     // Unique request ID (also in X-Request-ID header)

  // Parsed & validated (types from Zod)
  ctx.parsedParams;
  ctx.parsedQuery;
  ctx.parsedBody;

  // Auth
  ctx.user;          // Your user type

  // Metadata
  ctx.ip;
  ctx.userAgent;
  ctx.method;
  ctx.path;
}

Response Helpers

import { ApiResponse } from 'next-server-wrap';

// Success (return these)
ApiResponse.success(data)           // 200
ApiResponse.success(data, 201)      // custom status
ApiResponse.created(data)           // 201
ApiResponse.noContent()             // 204

// Custom response with headers
ApiResponse.response(data, {
  status: 202,
  headers: { 'X-Custom': 'value' },
})

// Errors (throw these)
throw ApiResponse.error('msg', 418, 'TEAPOT')  // custom error
throw ApiResponse.badRequest('msg')        // 400
throw ApiResponse.unauthorized('msg')      // 401
throw ApiResponse.forbidden('msg')         // 403
throw ApiResponse.notFound('msg')          // 404
throw ApiResponse.conflict('msg')          // 409
throw ApiResponse.validationError('msg', errors) // 422
throw ApiResponse.tooManyRequests('msg')   // 429
throw ApiResponse.internalError('msg')     // 500
throw ApiResponse.badGateway('msg')        // 502
throw ApiResponse.serviceUnavailable('msg') // 503
throw ApiResponse.gatewayTimeout('msg')    // 504

ActionResponse (Server Actions)

Server actions use ActionResponse which returns envelope objects instead of throwing errors.

import { ActionResponse } from 'next-server-wrap';

// Success - returns { success: true, data: T }
return ActionResponse.success(data);
return ActionResponse.success(data, 201);  // custom status
return ActionResponse.created(data);       // 201
return ActionResponse.noContent();         // 204, data is null

// Errors - throw these (internally throws ApiError, caught and converted to envelope)
throw ActionResponse.error('msg', 418, 'TEAPOT');
throw ActionResponse.badRequest('msg');
throw ActionResponse.unauthorized('msg');
throw ActionResponse.forbidden('msg');
throw ActionResponse.notFound('msg');
throw ActionResponse.conflict('msg');
throw ActionResponse.validationError('msg', errors);
throw ActionResponse.tooManyRequests('msg');
throw ActionResponse.internalError('msg');
throw ActionResponse.badGateway('msg');
throw ActionResponse.serviceUnavailable('msg');
throw ActionResponse.gatewayTimeout('msg');

Result types:

// Success result
{ success: true, data: T }

// Error result
{ success: false, error: { message: string, code: string, status: number, errors?: ValidationError[] } }

Adapters

Cache Adapter

Handles both response caching AND rate limiting via increment():

import { defineCacheAdapter } from 'next-server-wrap';

// In-memory example
const store = new Map<string, { value: unknown; expires: number }>();

const cacheAdapter = defineCacheAdapter({
  async get<T>(key: string): Promise<T | null> {
    const item = store.get(key);
    if (!item || item.expires < Date.now()) return null;
    return item.value as T;
  },

  async set(key: string, value: unknown, ttlMs = 60000): Promise<void> {
    store.set(key, { value, expires: Date.now() + ttlMs });
  },

  async delete(key: string): Promise<void> {
    store.delete(key);
  },

  async increment(key: string, ttlMs: number): Promise<number> {
    // Atomic increment - used for rate limiting
    const current = (store.get(key)?.value as number) || 0;
    const next = current + 1;
    store.set(key, { value: next, expires: Date.now() + ttlMs });
    return next;
  },
});

Redis example:

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

const cacheAdapter = defineCacheAdapter({
  async get(key) {
    const val = await redis.get(key);
    return val ? JSON.parse(val) : null;
  },
  async set(key, value, ttlMs = 60000) {
    await redis.set(key, JSON.stringify(value), 'PX', ttlMs);
  },
  async delete(key) {
    await redis.del(key);
  },
  async increment(key, ttlMs) {
    const count = await redis.incr(key);
    if (count === 1) await redis.pexpire(key, ttlMs);
    return count;
  },
});

Auth Adapter

import { defineAuthAdapter, type AuthRequestContext } from 'next-server-wrap';

const authAdapter = defineAuthAdapter<MyUser>({
  async verify(ctx: AuthRequestContext) {
    // ctx.headers - Headers object
    // ctx.cookies - { [name]: value }
    const token = ctx.headers.get('authorization')?.split(' ')[1];
    if (!token) return null;
    return verifyAndDecodeToken(token);
  },
  hasRole(user, roles) {
    return roles.length === 0 || roles.includes(user.role);
  },
  // Optional: for tenantScoped routes
  isTenantValid(user) {
    return !!user.companyId;
  },
});

Logger Adapter

import { defineLoggerAdapter } from 'next-server-wrap';

const loggerAdapter = defineLoggerAdapter<MyUser>({
  debug: (msg, meta) => console.debug(msg, meta),
  info: (msg, meta) => console.info(msg, meta),
  warn: (msg, meta) => console.warn(msg, meta),
  error: (msg, err, meta) => console.error(msg, err, meta),
  audit: (event) => {
    // event: { requestId, user, action, resource, resourceId, ip, userAgent, timestamp, durationMs }
    db.auditLog.create({ data: event });
  },
});

Response Headers

Every response includes:

  • X-Request-ID - Unique request identifier
  • X-Cache: HIT|MISS - Cache status (when caching enabled)
  • X-RateLimit-Limit - Rate limit max
  • X-RateLimit-Remaining - Remaining requests
  • X-RateLimit-Reset - Reset timestamp
  • Retry-After - Seconds until reset (on 429)

Default Rate Limits

When cache adapter is provided:

| Method | Limit | |--------|-------| | GET | 200/min | | POST | 50/min | | PUT | 50/min | | PATCH | 50/min | | DELETE | 20/min |

Disable with rateLimit: false.


Response Transformers

import { setGlobalTransformers } from 'next-server-wrap';

setGlobalTransformers({
  success: (data, status) => ({ ok: true, result: data }),
  error: (message, code, status, errors) => ({ ok: false, error: { message, code } }),
});

License

MIT