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 🙏

© 2025 – Pkg Stats / Ryan Hefner

next-expose

v0.1.12

Published

A fluent, type-safe API routing and middleware layer for the Next.js App Router.

Readme

next-expose

A fluent, type-safe API routing and middleware layer for the Next.js App Router.

npm version License: MIT TypeScript

Table of Contents

Introduction

next-expose is a powerful, type-safe routing and middleware library built specifically for Next.js App Router API routes. It provides a fluent, chainable API that makes building robust REST APIs simple and intuitive while maintaining full TypeScript support throughout the request pipeline.

Why next-expose?

  • 🔗 Fluent API: Chain methods naturally with .get().use(...).handle(...)
  • 🛡️ Type Safety: Full TypeScript support with progressive context typing
  • 🚀 Zero Config: Works out of the box with Next.js App Router
  • 🧩 Middleware System: Composable middleware with shared context
  • ⚡ Built-in Validation: Zod-powered request validation
  • 🎯 Error Handling: Structured error handling with custom error classes
  • 📦 Modular Design: Import only what you need

Features

  • Fluent Route Building: Intuitive method chaining for defining API routes
  • Progressive Type Safety: Context types evolve as middleware add properties
  • Built-in Validation: Request body and query parameter validation using Zod
  • Structured Error Handling: Custom error classes with automatic HTTP response mapping
  • Middleware System: Composable middleware with shared request context
  • Response Helpers: Pre-built response functions for common HTTP status codes
  • Next.js Integration: Seamless integration with Next.js App Router
  • Tree Shakeable: Modular exports for optimal bundle size

Installation

# npm
npm install next-expose

# yarn
yarn add next-expose

# pnpm
pnpm add next-expose

Peer Dependencies

next-expose requires Next.js as a peer dependency:

npm install next@^15.5.2

Quick Start

Create a simple API route in your Next.js app:

// app/api/users/route.ts
import { route } from 'next-expose';
import { Ok } from 'next-expose/responses';

export const { GET } = route()
  .get()
  .handle(async ({ req, ctx }) => {
    return Ok({ users: ['Alice', 'Bob', 'Charlie'] });
  })
  .expose();

Usage

Basic Route Handler

The simplest way to create an API route:

// app/api/hello/route.ts
import { route } from 'next-expose';
import { Ok } from 'next-expose/responses';

export const { GET } = route()
  .get()
  .handle(async ({ req, ctx }) => {
    return Ok({ message: 'Hello, World!' });
  })
  .expose();

Using Middleware

Add middleware to your route pipeline:

// app/api/protected/route.ts
import { route } from 'next-expose';
import { Ok, Unauthorized } from 'next-expose/responses';

// Custom authentication middleware
const authenticate = async ({ req, ctx, next }) => {
  const token = req.headers.get('authorization')?.replace('Bearer ', '');

  if (!token) {
    return Unauthorized('Missing authorization token');
  }

  // Add user info to context
  return next({ user: { id: '123', name: 'John Doe' } });
};

export const { GET } = route()
  .get()
  .use(authenticate)
  .handle(async ({ req, ctx }) => {
    // context.user is now available and typed
    return Ok({
      message: `Hello, ${ctx.user.name}!`,
      userId: ctx.user.id,
    });
  })
  .expose();

Request Body Validation

Validate incoming JSON using Zod schemas:

// app/api/users/route.ts
import { route } from 'next-expose';
import { validate } from 'next-expose/middlewares';
import { Created } from 'next-expose/responses';
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().min(18).optional(),
});

export const { POST } = route()
  .post()
  .use(validate(createUserSchema))
  .handle(async ({ req, ctx }) => {
    // context.body is typed according to the schema
    const { name, email, age } = ctx.body;

    // Create user logic here
    const user = { id: '123', name, email, age };

    return Created({ user });
  })
  .expose();

Query Parameter Validation

Validate URL query parameters:

// app/api/search/route.ts
import { route } from 'next-expose';
import { validateQuery } from 'next-expose/middlewares';
import { Ok } from 'next-expose/responses';
import { z } from 'zod';

const searchQuerySchema = z.object({
  q: z.string().min(1),
  limit: z
    .string()
    .transform((val) => parseInt(val))
    .pipe(z.number().min(1).max(100))
    .optional(),
  page: z
    .string()
    .transform((val) => parseInt(val))
    .pipe(z.number().min(1))
    .optional(),
});

export const { GET } = route()
  .get()
  .use(validateQuery(searchQuerySchema))
  .handle(async ({ req, ctx }) => {
    const { q, limit = 10, page = 1 } = ctx.query;

    // Search logic here
    const results = await searchItems(q, limit, page);

    return Ok({ results, query: q, limit, page });
  })
  .expose();

Error Handling

Use structured error classes:

// app/api/users/[id]/route.ts
import { route } from 'next-expose';
import { Ok } from 'next-expose/responses';
import { NotFoundError, BadRequestError } from 'next-expose/errors';

export const { GET } = route<{ id: string }>()
  .get()
  .handle(async ({ req, ctx }) => {
    const { id } = ctx.params;

    if (!id || typeof id !== 'string') {
      throw new BadRequestError('Invalid user ID');
    }

    const user = await getUserById(id);

    if (!user) {
      throw new NotFoundError(`User with ID ${id} not found`);
    }

    return Ok({ user });
  })
  .expose();

Multiple HTTP Methods

Handle multiple HTTP methods in one file:

// app/api/posts/route.ts
import { route } from 'next-expose';
import { validate } from 'next-expose/middlewares';
import { Ok, Created } from 'next-expose/responses';
import { z } from 'zod';

const createPostSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
});

export const { GET, POST } = route()
  .get()
  .handle(async ({ req, ctx }) => {
    const posts = await getAllPosts();
    return Ok({ posts });
  })
  .post()
  .use(validate(createPostSchema))
  .handle(async ({ req, ctx }) => {
    const { title, content } = ctx.body;
    const post = await createPost({ title, content });
    return Created({ post });
  })
  .expose();

API Reference

Core Functions

route<TParams>()

Creates a new route builder instance.

Type Parameters:

  • TParams - Shape of route parameters (e.g., { id: string })

Returns: ExposeRouter<TParams>

import { route } from 'next-expose';

// Basic route
const basicRoute = route();

// Route with typed parameters
const paramRoute = route<{ id: string; slug: string }>();

HTTP Method Builders

Each HTTP method returns a RouteMethodBuilder that supports middleware chaining:

.get(), .post(), .put(), .delete(), .patch(), .options(), .head()

route()
  .get() // GET requests
  .post() // POST requests
  .put() // PUT requests
  .delete() // DELETE requests
  .patch() // PATCH requests
  .options() // OPTIONS requests
  .head(); // HEAD requests

.use(middleware)

Adds middleware to the request pipeline.

Parameters:

  • middleware: ExposeMiddleware<TContextIn, TContextAdditions>

Returns: RouteMethodBuilder with evolved context type

.handle(finalHandler)

Sets the final request handler.

Parameters:

  • finalHandler: ExposeFinalHandler<TContext>

Returns: ExposeRouter with the configured method

.expose()

Finalizes the route configuration and returns Next.js-compatible handlers.

Returns: Object with configured HTTP method handlers

export const { GET, POST, DELETE } = route()
  .get()
  .handle(getHandler)
  .post()
  .handle(postHandler)
  .delete()
  .handle(deleteHandler)
  .expose();

Context Objects

ExposeContext<TParams>

The base context object passed through the middleware pipeline.

interface ExposeContext<TParams = Record<string, string | string[]>> {
  params: TParams;
}

Context Evolution

Middleware can add properties to the context:

// After validate middleware
interface ValidatedBodyContext<T> {
  body: T;
}

// After validateQuery middleware
interface ValidatedQueryContext<T> {
  query: T;
}

// After ipAddress middleware
interface IpContext {
  ip: string | null;
}

Built-in Middleware

validate(schema)

Validates request body against a Zod schema.

import { validate } from 'next-expose/middlewares';
import { z } from 'zod';

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

route()
  .post()
  .use(validate(schema))
  .handle(async ({ req, ctx }) => {
    // ctx.body is typed as { name: string; age: number }
  });

validateQuery(schema)

Validates URL query parameters against a Zod schema.

import { validateQuery } from 'next-expose/middlewares';
import { z } from 'zod';

const querySchema = z.object({
  search: z.string(),
  page: z.string().transform(Number),
});

route()
  .get()
  .use(validateQuery(querySchema))
  .handle(async ({ req, ctx }) => {
    // ctx.query is typed as { search: string; page: number }
  });

ipAddress

Extracts the client IP address from request headers.

import { ipAddress } from 'next-expose/middlewares';

route()
  .get()
  .use(ipAddress)
  .handle(async ({ req, ctx }) => {
    // ctx.ip is typed as string | null
    console.log('Client IP:', ctx.ip);
  });

Response Helpers

Pre-built response functions for common HTTP status codes:

import {
  Ok,
  Created,
  NoContent,
  BadRequest,
  Unauthorized,
  Forbidden,
  NotFound,
  UnprocessableEntity,
  InternalServerError,
  Conflict,
} from 'next-expose/responses';

// Success responses
Ok({ data: 'success' }); // 200
Created({ id: '123' }); // 201
NoContent(); // 204

// Error responses
BadRequest({ errors: ['Invalid input'] }); // 400
Unauthorized('Please log in'); // 401
Forbidden('Access denied'); // 403
NotFound('Resource not found'); // 404
UnprocessableEntity('Cannot process'); // 422
InternalServerError('Server error'); // 500
Conflict('Resource already exists'); // 409

Error Classes

Structured error classes for consistent error handling:

import {
  ApiError,
  BadRequestError,
  AuthenticationError,
  AuthorizationError,
  NotFoundError,
  ConflictError,
  ValidationError,
  InternalServerError,
  isApiError,
} from 'next-expose/errors';

// Throw structured errors
throw new NotFoundError('User not found');
throw new BadRequestError('Invalid request data');
throw new ValidationError({ field: ['Required'] }, 'Validation failed');

// Check error types
try {
  // API logic
} catch (error) {
  if (isApiError(error)) {
    // Handle known API errors
    console.log(`API Error ${error.statusCode}: ${error.message}`);
  }
}

Custom Middleware

Create your own middleware functions:

import type { ExposeMiddleware } from 'next-expose';

// Simple logging middleware
const logger: ExposeMiddleware = async ({ req, ctx, next }) => {
  console.log(`${req.method} ${req.url}`);
  return next();
};

// Authentication middleware with context additions
const authenticate: ExposeMiddleware<
  ExposeContext,
  { user: { id: string; role: string } }
> = async ({ req, ctx, next }) => {
  const token = req.headers.get('authorization');

  if (!token) {
    throw new AuthenticationError('Missing token');
  }

  const user = await verifyToken(token);
  return next({ user });
};

// Rate limiting middleware
const rateLimit = (limit: number): ExposeMiddleware => {
  return async ({ req, ctx, next }) => {
    const ip = req.headers.get('x-forwarded-for') || 'unknown';

    if (await isRateLimited(ip, limit)) {
      throw new BadRequestError('Rate limit exceeded');
    }

    return next();
  };
};

// Usage
route()
  .post()
  .use(logger)
  .use(authenticate)
  .use(rateLimit(100))
  .handle(async ({ req, ctx }) => {
    // ctx.user is available and typed
    return Ok({ message: `Hello ${ctx.user.id}` });
  });

Error Handling

next-expose provides centralized error handling:

Automatic Error Mapping

Thrown ApiError instances are automatically converted to appropriate HTTP responses:

// This error...
throw new NotFoundError('User not found');

// Becomes this response:
// HTTP 404
// {
//   "error": "Not Found",
//   "message": "User not found"
// }

Development vs Production

  • Development: Full error details are returned
  • Production: Generic error messages prevent information leakage

Custom Error Handling

For more complex error handling, you can create custom middleware:

const errorHandler: ExposeMiddleware = async ({ req, ctx, next }) => {
  try {
    return await next();
  } catch (error) {
    // Custom error logging
    await logError(error, req);

    // Re-throw to let next-expose handle the response
    throw error;
  }
};

TypeScript Support

next-expose provides full TypeScript support with progressive typing:

Route Parameters

// For route: app/api/users/[id]/posts/[postId]/route.ts
type RouteParams = {
  id: string;
  postId: string;
};

export const { GET } = route<RouteParams>()
  .get()
  .handle(async ({ req, ctx }) => {
    // ctx.params is typed as RouteParams
    const { id, postId } = ctx.params;
  });

Progressive Context Typing

export const { POST } = route()
  .post()
  .use(authenticate) // Adds { user: User }
  .use(validate(schema)) // Adds { validatedBody: T }
  .use(ipAddress) // Adds { ip: string | null }
  .handle(async ({ req, ctx }) => {
    // ctx has all properties with full type safety:
    // - ctx.params
    // - ctx.user
    // - ctx.validatedBody
    // - ctx.ip
  });

Best Practices

1. Use Structured Errors

Always use the provided error classes instead of throwing raw errors:

// ✅ Good
throw new NotFoundError('User not found');

// ❌ Bad
throw new Error('User not found');

2. Validate Input Early

Use validation middleware early in your pipeline:

export const { POST } = route()
  .post()
  .use(validate(inputSchema)) // Validate first
  .use(authenticate) // Then authenticate
  .use(authorize) // Then authorize
  .handle(businessLogic);

3. Type Your Route Parameters

Always provide type parameters for routes with dynamic segments:

// ✅ Good
route<{ id: string }>();

// ❌ Less ideal
route(); // params will be loosely typed

4. Keep Middleware Focused

Create single-purpose middleware functions:

// ✅ Good - focused middleware
const authenticate = async ({ req, ctx, next }) => {
  /* ... */
};
const authorize =
  (role: string) =>
  async ({ req, ctx, next }) => {
    /* ... */
  };

// ❌ Bad - doing too much in one middleware
const authAndValidate = async ({ req, ctx, next }) => {
  // Authentication + validation + authorization logic
};

5. Use Response Helpers

Always use the provided response helper functions:

// ✅ Good
return Ok({ users });
return Created({ user });

// ❌ Bad
return new Response(JSON.stringify({ users }), { status: 200 });

6. Handle Async Operations Properly

Always await async operations and handle potential errors:

export const { GET } = route()
  .get()
  .handle(async ({ req, ctx }) => {
    try {
      const data = await fetchExternalData();
      return Ok({ data });
    } catch (error) {
      throw new InternalServerError('Failed to fetch data');
    }
  });

Compatibility

Next.js Versions

  • Next.js 15.5.2+: Fully supported
  • Next.js 15.x: Compatible
  • Next.js 14.x: Not supported (use with caution)

Node.js Versions

  • Node.js 18+: Fully supported
  • Node.js 16+: Compatible but not recommended

Runtime Support

  • ✅ Node.js Runtime
  • ✅ Edge Runtime
  • ✅ Vercel
  • ✅ Netlify
  • ✅ CloudFlare Workers

Examples

Complete CRUD API

// app/api/users/route.ts
import { route } from 'next-expose';
import { validate, validateQuery } from 'next-expose/middlewares';
import { Ok, Created } from 'next-expose/responses';
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

const getUsersQuerySchema = z.object({
  page: z.string().transform(Number).pipe(z.number().min(1)).optional(),
  limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).optional(),
});

export const { GET, POST } = route()
  .get()
  .use(validateQuery(getUsersQuerySchema))
  .handle(async ({ req, ctx }) => {
    const { page = 1, limit = 10 } = ctx.query;
    const users = await getUsers({ page, limit });
    return Ok({ users, page, limit });
  })
  .post()
  .use(validate(createUserSchema))
  .handle(async ({ req, ctx }) => {
    const userData = ctx.body;
    const user = await createUser(userData);
    return Created({ user });
  })
  .expose();

Authentication & Authorization

// app/api/admin/users/route.ts
import { route } from 'next-expose';
import { Ok } from 'next-expose/responses';
import { AuthenticationError, AuthorizationError } from 'next-expose/errors';

const authenticate = async ({ req, ctx, next }) => {
  const token = req.headers.get('authorization')?.replace('Bearer ', '');

  if (!token) {
    throw new AuthenticationError();
  }

  const user = await verifyToken(token);
  return next({ user });
};

const requireAdmin = async ({ req, ctx, next }) => {
  if (ctx.user.role !== 'admin') {
    throw new AuthorizationError('Admin access required');
  }

  return next();
};

export const { GET } = route()
  .get()
  .use(authenticate)
  .use(requireAdmin)
  .handle(async ({ req, ctx }) => {
    const users = await getAllUsersAdmin();
    return Ok({ users });
  })
  .expose();

File Upload Handling

// app/api/upload/route.ts
import { route } from 'next-expose';
import { Created, BadRequest } from 'next-expose/responses';

export const { POST } = route()
  .post()
  .handle(async ({ req, ctx }) => {
    const formData = await req.formData();
    const file = formData.get('file') as File;

    if (!file) {
      return BadRequest({ message: 'No file provided' });
    }

    const uploadResult = await uploadFile(file);
    return Created({ file: uploadResult });
  })
  .expose();

Contributing

We welcome contributions! Please see our Contributing Guide for details.

Development Setup

  1. Fork and clone the repository:

    git clone https://github.com/your-username/next-expose.git
    cd next-expose
  2. Install dependencies:

    npm install
  3. Run tests:

    npm test
  4. Build the project:

    npm run build

Pull Request Process

  1. Create a feature branch: git checkout -b feature/amazing-feature
  2. Make your changes and add tests
  3. Ensure all tests pass: npm test
  4. Update documentation if needed
  5. Commit your changes: git commit -m 'Add amazing feature'
  6. Push to your fork: git push origin feature/amazing-feature
  7. Open a Pull Request

Coding Standards

  • Use TypeScript for all code
  • Follow the existing code style
  • Add tests for new features
  • Update documentation for public API changes
  • Ensure all tests pass before submitting

License

This project is licensed under the MIT License - see the LICENSE file for details.


Made with ❤️

For more information, visit our GitHub repository or report issues.