next-expose
Version:
A fluent, type-safe API routing and middleware layer for the Next.js App Router.
923 lines (702 loc) • 21.3 kB
Markdown
# next-expose
A fluent, type-safe API routing and middleware layer for the Next.js App Router.
[](https://badge.fury.io/js/next-expose)
[](https://opensource.org/licenses/MIT)
[](https://www.typescriptlang.org/)
## Table of Contents
- [Introduction](#introduction)
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Usage](#usage)
- [Basic Route Handler](#basic-route-handler)
- [Using Middleware](#using-middleware)
- [Request Body Validation](#request-body-validation)
- [Query Parameter Validation](#query-parameter-validation)
- [Error Handling](#error-handling)
- [Multiple HTTP Methods](#multiple-http-methods)
- [API Reference](#api-reference)
- [Core Functions](#core-functions)
- [Middleware](#middleware)
- [Response Helpers](#response-helpers)
- [Error Classes](#error-classes)
- [Built-in Middleware](#built-in-middleware)
- [Custom Middleware](#custom-middleware)
- [Error Handling](#error-handling-1)
- [TypeScript Support](#typescript-support)
- [Best Practices](#best-practices)
- [Compatibility](#compatibility)
- [Examples](#examples)
- [Contributing](#contributing)
- [License](#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
```bash
# 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:
```bash
npm install next@^15.5.2
```
## Quick Start
Create a simple API route in your Next.js app:
```typescript
// app/api/users/route.ts
import { route } from 'next-expose';
import { Ok } from 'next-expose/responses';
export const { GET } = route()
.get()
.handle(async (req, context) => {
return Ok({ users: ['Alice', 'Bob', 'Charlie'] });
})
.expose();
```
## Usage
### Basic Route Handler
The simplest way to create an API route:
```typescript
// app/api/hello/route.ts
import { route } from 'next-expose';
import { Ok } from 'next-expose/responses';
export const { GET } = route()
.get()
.handle(async (req, context) => {
return Ok({ message: 'Hello, World!' });
})
.expose();
```
### Using Middleware
Add middleware to your route pipeline:
```typescript
// app/api/protected/route.ts
import { route } from 'next-expose';
import { Ok, Unauthorized } from 'next-expose/responses';
// Custom authentication middleware
const authenticate = async (req, context, 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, context) => {
// context.user is now available and typed
return Ok({
message: `Hello, ${context.user.name}!`,
userId: context.user.id,
});
})
.expose();
```
### Request Body Validation
Validate incoming JSON using Zod schemas:
```typescript
// 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, context) => {
// context.validatedBody is typed according to the schema
const { name, email, age } = context.validatedBody;
// Create user logic here
const user = { id: '123', name, email, age };
return Created({ user });
})
.expose();
```
### Query Parameter Validation
Validate URL query parameters:
```typescript
// 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, context) => {
const { q, limit = 10, page = 1 } = context.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:
```typescript
// 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, context) => {
const { id } = context.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:
```typescript
// 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, context) => {
const posts = await getAllPosts();
return Ok({ posts });
})
.post()
.use(validate(createPostSchema))
.handle(async (req, context) => {
const { title, content } = context.validatedBody;
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>`
```typescript
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()`
```typescript
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
```typescript
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.
```typescript
interface ExposeContext<TParams = Record<string, string | string[]>> {
params: TParams;
}
```
#### Context Evolution
Middleware can add properties to the context:
```typescript
// After validate middleware
interface ValidatedBodyContext<T> {
validatedBody: 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.
```typescript
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, context) => {
// context.validatedBody is typed as { name: string; age: number }
});
```
### `validateQuery(schema)`
Validates URL query parameters against a Zod schema.
```typescript
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, context) => {
// context.query is typed as { search: string; page: number }
});
```
### `ipAddress`
Extracts the client IP address from request headers.
```typescript
import { ipAddress } from 'next-expose/middlewares';
route()
.get()
.use(ipAddress)
.handle(async (req, context) => {
// context.ip is typed as string | null
console.log('Client IP:', context.ip);
});
```
## Response Helpers
Pre-built response functions for common HTTP status codes:
```typescript
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:
```typescript
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:
```typescript
import type { ExposeMiddleware } from 'next-expose';
// Simple logging middleware
const logger: ExposeMiddleware = async (req, context, 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, context, 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, context, 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, context) => {
// context.user is available and typed
return Ok({ message: `Hello ${context.user.id}` });
});
```
## Error Handling
`next-expose` provides centralized error handling:
### Automatic Error Mapping
Thrown `ApiError` instances are automatically converted to appropriate HTTP responses:
```typescript
// 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:
```typescript
const errorHandler: ExposeMiddleware = async (req, context, 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
```typescript
// 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, context) => {
// context.params is typed as RouteParams
const { id, postId } = context.params;
});
```
### Progressive Context Typing
```typescript
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, context) => {
// context has all properties with full type safety:
// - context.params
// - context.user
// - context.validatedBody
// - context.ip
});
```
## Best Practices
### 1. Use Structured Errors
Always use the provided error classes instead of throwing raw errors:
```typescript
// ✅ 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:
```typescript
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:
```typescript
// ✅ Good
route<{ id: string }>();
// ❌ Less ideal
route(); // params will be loosely typed
```
### 4. Keep Middleware Focused
Create single-purpose middleware functions:
```typescript
// ✅ Good - focused middleware
const authenticate = async (req, context, next) => {
/* ... */
};
const authorize = (role: string) => async (req, context, next) => {
/* ... */
};
// ❌ Bad - doing too much in one middleware
const authAndValidate = async (req, context, next) => {
// Authentication + validation + authorization logic
};
```
### 5. Use Response Helpers
Always use the provided response helper functions:
```typescript
// ✅ 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:
```typescript
export const { GET } = route()
.get()
.handle(async (req, context) => {
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
```typescript
// 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, context) => {
const { page = 1, limit = 10 } = context.query;
const users = await getUsers({ page, limit });
return Ok({ users, page, limit });
})
.post()
.use(validate(createUserSchema))
.handle(async (req, context) => {
const userData = context.validatedBody;
const user = await createUser(userData);
return Created({ user });
})
.expose();
```
### Authentication & Authorization
```typescript
// 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, context, 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, context, next) => {
if (context.user.role !== 'admin') {
throw new AuthorizationError('Admin access required');
}
return next();
};
export const { GET } = route()
.get()
.use(authenticate)
.use(requireAdmin)
.handle(async (req, context) => {
const users = await getAllUsersAdmin();
return Ok({ users });
})
.expose();
```
### File Upload Handling
```typescript
// 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, context) => {
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](CONTRIBUTING.md) for details.
### Development Setup
1. **Fork and clone the repository:**
```bash
git clone https://github.com/your-username/next-expose.git
cd next-expose
```
2. **Install dependencies:**
```bash
npm install
```
3. **Run tests:**
```bash
npm test
```
4. **Build the project:**
```bash
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](LICENSE) file for details.
---
**Made with ❤️**
For more information, visit our [GitHub repository](https://github.com/eddiedane/next-expose) or [report issues](https://github.com/eddiedane/next-expose/issues).