next-form-request
v2.0.1
Published
Laravel-inspired Form Request validation for Next.js API routes
Maintainers
Readme
next-form-request
Laravel-inspired Form Request validation for Next.js API routes. Bring the elegance of Laravel's form requests to your Next.js applications with full TypeScript support and type inference.
Features
- Laravel-style Form Requests - Familiar
rules(),authorize(),beforeValidation(), andafterValidation()hooks - Full TypeScript Support - Complete type inference for validated data with automatic IDE completion
- Validator Agnostic - Built-in adapters for Zod, Yup, Valibot, and ArkType, or bring your own
- Works with Both Routers - Seamless support for App Router (Next.js 13+) and Pages Router
- Built-in Rate Limiting - Protect your endpoints with configurable rate limiting
- File Upload Validation - Comprehensive file validation with size, type, and extension checks
- Automatic Type Coercion - Convert form data strings to appropriate JavaScript types
- Flexible Middleware - Convenient wrapper functions or manual instantiation
- Comprehensive Testing Utilities - Helper functions for testing form requests
- Composition Patterns - Reusable base classes and composable authorisation logic
Installation
npm install next-form-requestWith your preferred validator:
# Zod (recommended)
npm install next-form-request zod
# Yup
npm install next-form-request yup
# Valibot
npm install next-form-request valibot
# ArkType
npm install next-form-request arktypeQuick Start
1. Define a Form Request
// requests/CreateUserRequest.ts
import { FormRequest, ZodAdapter } from 'next-form-request';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
name: z.string().min(2),
password: z.string().min(8),
});
export class CreateUserRequest extends FormRequest<z.infer<typeof schema>> {
rules() {
return new ZodAdapter(schema);
}
async authorize() {
// Add authorization logic here
return true;
}
beforeValidation() {
// Normalise input before validation
if (this.body.email) {
this.body.email = this.body.email.toLowerCase().trim();
}
}
}2. Use in Your API Route
App Router (Next.js 13+)
// app/api/users/route.ts
import { CreateUserRequest } from '@/requests/CreateUserRequest';
import { ValidationError, AuthorizationError } from 'next-form-request';
export async function POST(request: Request) {
try {
const form = await CreateUserRequest.fromAppRouter(request);
const data = await form.validate();
// data is fully typed as { email: string; name: string; password: string }
const user = await db.users.create({ data });
return Response.json({ user }, { status: 201 });
} catch (error) {
if (error instanceof ValidationError) {
return Response.json({ errors: error.errors }, { status: 422 });
}
if (error instanceof AuthorizationError) {
return Response.json({ message: 'Forbidden' }, { status: 403 });
}
throw error;
}
}Pages Router
// pages/api/users.ts
import { CreateUserRequest } from '@/requests/CreateUserRequest';
import { ValidationError, AuthorizationError } from 'next-form-request';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const form = await CreateUserRequest.fromPagesRouter(req);
const data = await form.validate();
const user = await db.users.create({ data });
return res.status(201).json({ user });
} catch (error) {
if (error instanceof ValidationError) {
return res.status(422).json({ errors: error.errors });
}
if (error instanceof AuthorizationError) {
return res.status(403).json({ message: 'Forbidden' });
}
throw error;
}
}Core Concepts
FormRequest Class
The FormRequest class is an abstract base class that you extend to create your validation requests. It provides:
- Lifecycle hooks for authorization and data transformation
- Helper methods for accessing request data
- Automatic type inference for validated data
- Support for both App Router and Pages Router
Validation Adapters
Adapters provide a unified interface for different validation libraries. Available adapters:
ZodAdapter- For Zod schemasYupAdapter- For Yup schemasValibotAdapter- For Valibot schemasArkTypeAdapter- For ArkType schemas
Middleware Wrappers
Wrapper functions provide a cleaner API for common use cases:
withRequest- App Router wrapper with full FormRequest featureswithApiRequest- Pages Router wrapper with full FormRequest featureswithSchema- Lightweight App Router wrapper for schema-only validationwithApiSchema- Lightweight Pages Router wrapper for schema-only validation
Validation Adapters
Zod (Recommended)
import { FormRequest, ZodAdapter } from 'next-form-request';
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(50),
age: z.number().int().positive().optional(),
role: z.enum(['user', 'admin']),
});
export class CreateUserRequest extends FormRequest<z.infer<typeof userSchema>> {
rules() {
return new ZodAdapter(userSchema);
}
}Yup
import { FormRequest, YupAdapter } from 'next-form-request';
import * as yup from 'yup';
const userSchema = yup.object({
email: yup.string().email().required(),
name: yup.string().min(2).max(50).required(),
age: yup.number().positive().integer().optional(),
});
export class CreateUserRequest extends FormRequest<yup.InferType<typeof userSchema>> {
rules() {
return new YupAdapter(userSchema);
}
}Valibot
import { FormRequest, ValibotAdapter } from 'next-form-request';
import * as v from 'valibot';
const userSchema = v.object({
email: v.pipe(v.string(), v.email()),
name: v.pipe(v.string(), v.minLength(2), v.maxLength(50)),
age: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
});
export class CreateUserRequest extends FormRequest<v.InferOutput<typeof userSchema>> {
rules() {
return new ValibotAdapter(userSchema);
}
}ArkType
import { FormRequest, ArkTypeAdapter } from 'next-form-request';
import { type } from 'arktype';
const userSchema = type({
email: 'email',
name: 'string>2',
age: 'number>0',
});
export class CreateUserRequest extends FormRequest<typeof userSchema.infer> {
rules() {
return new ArkTypeAdapter(userSchema);
}
}Middleware Wrappers
withRequest (App Router)
Clean wrapper for App Router with full FormRequest lifecycle:
// app/api/users/route.ts
import { withRequest } from 'next-form-request';
import { CreateUserRequest } from '@/requests/CreateUserRequest';
export const POST = withRequest(CreateUserRequest, async (data, request, formRequest) => {
// data is fully typed
// request is the original Request object
// formRequest is the CreateUserRequest instance
const user = await db.users.create({ data });
return Response.json({ user }, { status: 201 });
});withApiRequest (Pages Router)
Clean wrapper for Pages Router with full FormRequest lifecycle:
// pages/api/users.ts
import { withApiRequest } from 'next-form-request';
import { CreateUserRequest } from '@/requests/CreateUserRequest';
export default withApiRequest(CreateUserRequest, async (data, req, res, formRequest) => {
// data is fully typed
const user = await db.users.create({ data });
res.status(201).json({ user });
});withSchema (Lightweight App Router)
For simple schema validation without hooks:
// app/api/products/route.ts
import { withSchema, ZodAdapter } from 'next-form-request';
import { z } from 'zod';
const productSchema = z.object({
name: z.string().min(2),
price: z.number().positive(),
});
export const POST = withSchema(new ZodAdapter(productSchema), async (data, request) => {
// data is typed as { name: string; price: number }
const product = await db.products.create({ data });
return Response.json({ product }, { status: 201 });
});withApiSchema (Lightweight Pages Router)
For simple schema validation without hooks:
// pages/api/products.ts
import { withApiSchema, ZodAdapter } from 'next-form-request';
import { z } from 'zod';
const productSchema = z.object({
name: z.string().min(2),
price: z.number().positive(),
});
export default withApiSchema(new ZodAdapter(productSchema), async (data, req, res) => {
const product = await db.products.create({ data });
res.status(201).json({ product });
});Custom Error Handling
Create wrappers with custom error handlers:
import { createAppRouterWrapper, ValidationError, AuthorizationError } from 'next-form-request';
const withValidation = createAppRouterWrapper({
onValidationError: (error) =>
Response.json({ errors: error.errors }, { status: 422 }),
onAuthorizationError: () =>
Response.json({ message: 'Forbidden' }, { status: 403 }),
onError: (error) => {
console.error(error);
return Response.json({ message: 'Internal Server Error' }, { status: 500 });
},
});
export const POST = withValidation(CreateUserRequest, async (data) => {
const user = await db.users.create({ data });
return Response.json({ user }, { status: 201 });
});Lifecycle Hooks
FormRequest supports Laravel-style lifecycle hooks for complete control over the validation process.
authorize()
Control who can make the request:
class UpdatePostRequest extends FormRequest<PostData> {
rules() {
return new ZodAdapter(schema);
}
async authorize() {
const session = await getSession(this.request);
const postId = this.param('id');
const post = await db.posts.findUnique({ where: { id: postId } });
// Only the author can update the post
return post?.authorId === session?.user?.id;
}
onAuthorizationFailed() {
console.log('Unauthorised update attempt on post:', this.param('id'));
}
}beforeValidation()
Transform input data before validation:
class CreateUserRequest extends FormRequest<UserData> {
rules() {
return new ZodAdapter(schema);
}
beforeValidation() {
// Normalise email
if (this.body.email) {
this.body.email = this.body.email.toLowerCase().trim();
}
// Strip whitespace from all string fields
for (const [key, value] of Object.entries(this.body)) {
if (typeof value === 'string') {
this.body[key] = value.trim();
}
}
}
}afterValidation()
Process data after successful validation:
class CreateOrderRequest extends FormRequest<OrderData> {
rules() {
return new ZodAdapter(schema);
}
async afterValidation(data: OrderData) {
// Log successful order creation
await analytics.track('order_validated', {
userId: data.userId,
amount: data.total,
});
}
}onValidationFailed()
Handle validation failures:
class LoginRequest extends FormRequest<LoginData> {
rules() {
return new ZodAdapter(schema);
}
async onValidationFailed(errors: ValidationErrors) {
// Track failed login attempts
await analytics.track('login_validation_failed', {
email: this.input('email'),
errors: Object.keys(errors),
});
}
}rateLimit()
Add rate limiting to protect endpoints:
class LoginRequest extends FormRequest<LoginData> {
rules() {
return new ZodAdapter(schema);
}
rateLimit() {
return {
maxAttempts: 5,
windowMs: 60000, // 1 minute
key: (req) => this.input('email') || 'anonymous',
message: 'Too many login attempts. Please try again later.',
};
}
}coercion()
Automatically convert string values to appropriate types:
class UpdateSettingsRequest extends FormRequest<SettingsData> {
rules() {
return new ZodAdapter(schema);
}
coercion() {
return {
booleans: true, // "true" → true
numbers: true, // "123" → 123
dates: true, // "2024-01-01" → Date
nulls: true, // "null" → null
};
}
}Utilities
File Uploads
Validate file uploads with comprehensive options:
import { FormRequest, ZodAdapter, formFile, formFiles } from 'next-form-request';
import { z } from 'zod';
const uploadSchema = z.object({
// Single file
avatar: formFile({
maxSize: '5mb',
types: ['image/*'],
extensions: ['jpg', 'png', 'webp'],
}),
// Multiple files
documents: formFiles({
maxSize: '10mb',
types: ['application/pdf', 'application/msword'],
minFiles: 1,
maxFiles: 5,
}),
});
export class UploadRequest extends FormRequest<z.infer<typeof uploadSchema>> {
rules() {
return new ZodAdapter(uploadSchema);
}
}
// Usage
const data = await form.validate();
console.log(data.avatar.name); // "profile.jpg"
console.log(data.avatar.size); // 245678
console.log(data.avatar.type); // "image/jpeg"
const buffer = await data.avatar.arrayBuffer();
const text = await data.avatar.text();Rate Limiting
Protect your API routes from abuse:
import { rateLimit, RateLimitError } from 'next-form-request';
class ApiRequest extends FormRequest<RequestData> {
rules() {
return new ZodAdapter(schema);
}
rateLimit() {
return rateLimit({
maxAttempts: 100,
windowMs: 60000, // 1 minute
key: async (req) => {
// Rate limit by API key
const apiKey = this.header('x-api-key');
return apiKey || 'anonymous';
},
});
}
}
// Handle rate limit errors
try {
const data = await form.validate();
} catch (error) {
if (error instanceof RateLimitError) {
return Response.json(
{ message: error.message },
{
status: 429,
headers: error.getHeaders(), // X-RateLimit-* headers
}
);
}
}Type Coercion
Automatically convert form data strings:
import { coerceFormData, coercionPresets } from 'next-form-request';
const formData = {
name: "John",
age: "25",
active: "true",
score: "98.5",
createdAt: "2024-01-01T12:00:00Z",
};
const coerced = coerceFormData(formData, coercionPresets.standard);
// {
// name: "John",
// age: 25,
// active: true,
// score: 98.5,
// createdAt: Date("2024-01-01T12:00:00Z"),
// }Use in FormRequest:
class MyRequest extends FormRequest<Data> {
rules() {
return new ZodAdapter(schema);
}
coercion() {
return {
booleans: true,
numbers: true,
dates: true,
fields: {
// Custom coercion for specific fields
'metadata': (value) => JSON.parse(value),
},
};
}
}Error Formatting
Format validation errors for different use cases:
import { formatErrors, flattenErrors, summarizeErrors } from 'next-form-request';
const errors = {
email: ['Email is invalid', 'Email is required'],
password: ['Password must be at least 8 characters'],
'address.postcode': ['Postcode is invalid'],
};
// Structured format with metadata
const formatted = formatErrors(errors, {
includeCount: true,
includeFields: true,
});
// {
// errors: { ... },
// meta: { count: 3, fields: ['email', 'password', 'address.postcode'] }
// }
// Flat array of all messages
const flat = flattenErrors(errors);
// ['Email is invalid', 'Email is required', 'Password must be...', ...]
// Summary string
const summary = summarizeErrors(errors);
// "Email is invalid, Password must be at least 8 characters, Postcode is invalid"Testing Utilities
Comprehensive utilities for testing form requests:
import {
testFormRequest,
createMockRequest,
expectValid,
expectInvalid,
expectFieldError,
} from 'next-form-request';
describe('CreateUserRequest', () => {
it('validates correct data', async () => {
const result = await testFormRequest(CreateUserRequest, {
email: '[email protected]',
name: 'John Doe',
password: 'password123',
});
expectValid(result);
expect(result.data.email).toBe('[email protected]');
});
it('rejects invalid email', async () => {
const result = await testFormRequest(CreateUserRequest, {
email: 'invalid-email',
name: 'John Doe',
password: 'password123',
});
expectInvalid(result);
expectFieldError(result, 'email');
});
it('tests with custom request', async () => {
const mockRequest = createMockRequest({
method: 'POST',
body: { email: '[email protected]', name: 'John' },
headers: { 'x-api-key': 'test-key' },
});
const form = await CreateUserRequest.fromAppRouter(mockRequest);
const data = await form.validate();
expect(data.email).toBe('[email protected]');
});
});Advanced Patterns
Composition
Create reusable base classes:
import { FormRequest, createAuthenticatedRequest } from 'next-form-request';
// Base authenticated request
const AuthenticatedRequest = createAuthenticatedRequest({
async getUser(request) {
const session = await getSession(request);
return session?.user;
},
});
// Use in your requests
class CreatePostRequest extends AuthenticatedRequest<PostData> {
rules() {
return new ZodAdapter(postSchema);
}
async authorize() {
// this.user is available from base class
return this.user?.role === 'admin';
}
}Custom Messages
Override default error messages:
class CreateUserRequest extends FormRequest<UserData> {
rules() {
return new ZodAdapter(schema);
}
messages() {
return {
'email.invalid_string': 'Please provide a valid email address',
'email.required': 'Email is required',
'password.too_small': 'Password must be at least 8 characters long',
'name': 'Please provide your full name',
};
}
attributes() {
return {
email: 'email address',
dob: 'date of birth',
postcode: 'postal code',
};
}
}Helper Methods
Access request data with convenient helpers:
const form = await MyRequest.fromAppRouter(request, { id: '123' });
// Input values
form.input('email'); // Get a value
form.input('missing', 'default'); // With default
form.has('email'); // Check existence
form.all(); // Get all body data
// Filtering
form.only('email', 'name'); // Only these keys
form.except('password', 'token'); // All except these
// Request data
form.param('id'); // Route parameter (from URL)
form.header('content-type'); // Header value
form.getRequest(); // Original request object
form.isAppRouter(); // Check router type
// After validation
const data = await form.validate();
form.validated(); // Get validated data again
form.safe(); // Get partial validated dataReusable Base Classes
Create domain-specific base requests:
// Base class for all API requests
abstract class ApiRequest<T> extends FormRequest<T> {
async authorize() {
const apiKey = this.header('x-api-key');
return apiKey === process.env.API_KEY;
}
rateLimit() {
return {
maxAttempts: 100,
windowMs: 60000,
key: () => this.header('x-api-key') || 'anonymous',
};
}
}
// Use the base class
class CreateWebhookRequest extends ApiRequest<WebhookData> {
rules() {
return new ZodAdapter(webhookSchema);
}
// authorize() and rateLimit() inherited
}Conditional Validation
Adjust validation based on request data:
class UpdateUserRequest extends FormRequest<UserData> {
rules() {
const isAdmin = this.input('role') === 'admin';
const baseSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
});
if (isAdmin) {
return new ZodAdapter(baseSchema.extend({
permissions: z.array(z.string()),
department: z.string(),
}));
}
return new ZodAdapter(baseSchema);
}
}API Reference
FormRequest
Abstract Methods
| Method | Description |
|--------|-------------|
| rules() | Required. Return a ValidatorAdapter instance for validation |
Lifecycle Hooks
| Method | Description |
|--------|-------------|
| authorize() | Return true to allow request, false to reject with 403 |
| beforeValidation() | Transform this.body before validation runs |
| afterValidation(data) | Called after successful validation with typed data |
| onValidationFailed(errors) | Called when validation fails |
| onAuthorizationFailed() | Called when authorization fails |
| rateLimit() | Return rate limit configuration or null |
| coercion() | Return coercion options or null |
Customisation
| Method | Description |
|--------|-------------|
| messages() | Return custom error messages as Record<string, string> |
| attributes() | Return custom field names for error messages |
Static Factory Methods
| Method | Description |
|--------|-------------|
| fromAppRouter(request, params?) | Create instance from App Router Request |
| fromPagesRouter(request, params?) | Create instance from Pages Router NextApiRequest |
Instance Methods
| Method | Description |
|--------|-------------|
| validate() | Run validation and return typed data (throws on failure) |
| validated() | Get validated data (must call validate() first) |
| safe() | Get partial validated data (safe to call any time) |
| all() | Get all input data as object |
| input(key, default?) | Get input value with optional default |
| has(key) | Check if input key exists |
| only(...keys) | Get only specified keys from input |
| except(...keys) | Get all input except specified keys |
| param(name) | Get route parameter value |
| header(name) | Get request header value |
| getRequest() | Get original request object |
| isAppRouter() | Check if App Router request |
Wrapper Functions
App Router
| Function | Description |
|----------|-------------|
| withRequest(RequestClass, handler) | Wrap route handler with full FormRequest |
| withSchema(adapter, handler) | Wrap route handler with schema validation only |
| createAppRouterWrapper(options) | Create custom wrapper with error handlers |
Pages Router
| Function | Description |
|----------|-------------|
| withApiRequest(RequestClass, handler) | Wrap API handler with full FormRequest |
| withApiSchema(adapter, handler) | Wrap API handler with schema validation only |
| createPagesRouterWrapper(options) | Create custom wrapper with error handlers |
Error Classes
| Class | Description |
|-------|-------------|
| ValidationError | Thrown when validation fails. Has .errors property |
| AuthorizationError | Thrown when authorize() returns false |
| RateLimitError | Thrown when rate limit exceeded. Has .getHeaders() method |
Utilities
| Export | Description |
|--------|-------------|
| formFile(options) | Create Zod schema for single file upload |
| formFiles(options) | Create Zod schema for multiple file uploads |
| coerceFormData(data, options) | Coerce string values to appropriate types |
| formatErrors(errors, options) | Format validation errors with metadata |
| testFormRequest(RequestClass, data) | Test helper for form requests |
TypeScript Support
The library provides full TypeScript support with automatic type inference:
const schema = z.object({
email: z.string().email(),
age: z.number(),
role: z.enum(['user', 'admin']),
});
class MyRequest extends FormRequest<z.infer<typeof schema>> {
rules() {
return new ZodAdapter(schema);
}
}
// In your route
export const POST = withRequest(MyRequest, async (data) => {
// data is typed as { email: string; age: number; role: 'user' | 'admin' }
data.email; // ✓ string
data.age; // ✓ number
data.role; // ✓ 'user' | 'admin'
data.foo; // ✗ TypeScript error
return Response.json({ data });
});Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
MIT © Sam Street
