next-expose
v0.1.12
Published
A fluent, type-safe API routing and middleware layer for the Next.js App Router.
Maintainers
Readme
next-expose
A fluent, type-safe API routing and middleware layer for the Next.js App Router.
Table of Contents
- Introduction
- Features
- Installation
- Quick Start
- Usage
- API Reference
- Built-in Middleware
- Custom Middleware
- Error Handling
- TypeScript Support
- Best Practices
- Compatibility
- Examples
- Contributing
- License
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-exposePeer Dependencies
next-expose requires Next.js as a peer dependency:
npm install next@^15.5.2Quick 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'); // 409Error 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 typed4. 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
Fork and clone the repository:
git clone https://github.com/your-username/next-expose.git cd next-exposeInstall dependencies:
npm installRun tests:
npm testBuild the project:
npm run build
Pull Request Process
- Create a feature branch:
git checkout -b feature/amazing-feature - Make your changes and add tests
- Ensure all tests pass:
npm test - Update documentation if needed
- Commit your changes:
git commit -m 'Add amazing feature' - Push to your fork:
git push origin feature/amazing-feature - 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.
