UNPKG

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
# next-expose A fluent, type-safe API routing and middleware layer for the Next.js App Router. [![npm version](https://badge.fury.io/js/next-expose.svg)](https://badge.fury.io/js/next-expose) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](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).