Skip to main content

Next.js API Validation

Introduction

When building APIs in Next.js, ensuring that the data you receive matches your expected format and requirements is crucial for maintaining the integrity and security of your application. API validation is the process of checking incoming requests before processing them, which helps prevent bugs, security vulnerabilities, and improper data manipulation.

In this guide, we'll explore different approaches to validate incoming requests to your Next.js API routes, from basic validation using JavaScript to more advanced solutions with dedicated validation libraries.

Why Validate API Requests?

Before diving into implementation, let's understand why validation is important:

  1. Security: Prevents malicious inputs that could lead to attacks like SQL injection
  2. Data Integrity: Ensures your database contains properly formatted data
  3. Better Error Handling: Provides meaningful error messages to API consumers
  4. Reduced Bugs: Catches errors early before they propagate through your system

Basic Validation with JavaScript

The simplest way to validate API requests is using vanilla JavaScript. Let's start with a basic example:

javascript
// pages/api/create-user.js
export default function handler(req, res) {
// Check if the request method is POST
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}

// Extract data from request body
const { username, email, password } = req.body;

// Basic validation
if (!username) {
return res.status(400).json({ error: 'Username is required' });
}

if (!email || !/^\S+@\S+\.\S+$/.test(email)) {
return res.status(400).json({ error: 'Valid email is required' });
}

if (!password || password.length < 8) {
return res.status(400).json({
error: 'Password is required and must be at least 8 characters long'
});
}

// If validation passes, proceed with creating the user
// ...

return res.status(201).json({ message: 'User created successfully' });
}

This approach works for simple cases, but it can become verbose and difficult to maintain as your validation rules become more complex.

Using Zod for Schema Validation

Zod is a TypeScript-first schema validation library that allows you to define schemas and validate data against them. It's particularly powerful when used with Next.js API routes.

First, install Zod:

bash
npm install zod

Now, let's refactor our previous example using Zod:

javascript
// pages/api/create-user.js
import { z } from 'zod';

// Define validation schema
const UserSchema = z.object({
username: z.string().min(1, "Username is required"),
email: z.string().email("Invalid email format"),
password: z.string().min(8, "Password must be at least 8 characters long")
});

export default function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}

try {
// Validate request body against schema
const validatedData = UserSchema.parse(req.body);

// If validation passes, proceed with the validated data
// ...

return res.status(201).json({ message: 'User created successfully' });
} catch (error) {
// Handle validation errors
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}

return res.status(500).json({ error: 'Internal server error' });
}
}

Input and Output Example

Let's see an example of what happens when we send different requests to this API:

Valid request:

json
// Request body
{
"username": "johndoe",
"email": "[email protected]",
"password": "securepw123"
}

// Response (HTTP 201)
{
"message": "User created successfully"
}

Invalid request:

json
// Request body
{
"username": "",
"email": "notanemail",
"password": "short"
}

// Response (HTTP 400)
{
"error": "Validation failed",
"details": [
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"message": "Username is required",
"path": ["username"]
},
{
"validation": "email",
"code": "invalid_string",
"message": "Invalid email format",
"path": ["email"]
},
{
"code": "too_small",
"minimum": 8,
"type": "string",
"inclusive": true,
"message": "Password must be at least 8 characters long",
"path": ["password"]
}
]
}

Creating a Validation Middleware

To avoid repeating validation logic in multiple API routes, you can create a reusable middleware:

javascript
// lib/middleware/validateRequest.js
import { NextApiRequest, NextApiResponse } from 'next';
import { ZodSchema } from 'zod';

export function validateRequest(schema) {
return async (req, res, next) => {
try {
const validatedBody = schema.parse(req.body);
req.validatedBody = validatedBody;
return next();
} catch (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}
};
}

To use this middleware with Next.js API routes, you'll need to adapt it slightly since Next.js doesn't have built-in middleware support like Express. You can use a wrapper pattern:

javascript
// pages/api/create-user.js
import { z } from 'zod';

const UserSchema = z.object({
username: z.string().min(1, "Username is required"),
email: z.string().email("Invalid email format"),
password: z.string().min(8, "Password must be at least 8 characters long")
});

const withValidation = (schema, handler) => async (req, res) => {
try {
req.validatedBody = schema.parse(req.body);
return handler(req, res);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}
return res.status(500).json({ error: 'Internal server error' });
}
};

async function createUserHandler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}

// Access validated data
const { username, email, password } = req.validatedBody;

// Process the data...

return res.status(201).json({ message: 'User created successfully' });
}

export default withValidation(UserSchema, createUserHandler);

Advanced Validation Techniques

Query Parameters Validation

Don't forget to validate query parameters as well:

javascript
// pages/api/users.js
import { z } from 'zod';

const QuerySchema = z.object({
page: z.string().transform(Number).optional().default('1'),
limit: z.string().transform(Number).optional().default('10'),
search: z.string().optional(),
});

export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}

try {
// Validate query parameters
const { page, limit, search } = QuerySchema.parse(req.query);

// Use the validated query params
// ...

return res.status(200).json({ /* result data */ });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Query validation failed',
details: error.errors
});
}

return res.status(500).json({ error: 'Internal server error' });
}
}

File Upload Validation

For APIs that handle file uploads, you can validate file types, sizes, and other properties:

javascript
// pages/api/upload.js
import formidable from 'formidable';
import fs from 'fs';

export const config = {
api: {
bodyParser: false, // Disable built-in body parser for file uploads
},
};

export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}

return new Promise((resolve, reject) => {
const form = formidable({
maxFileSize: 5 * 1024 * 1024, // 5MB
});

form.parse(req, (err, fields, files) => {
if (err) {
res.status(400).json({ error: 'File upload failed', details: err.message });
return resolve();
}

const file = files.file;

if (!file) {
res.status(400).json({ error: 'No file uploaded' });
return resolve();
}

// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.mimetype)) {
res.status(400).json({
error: 'Invalid file type',
details: 'Only JPEG, PNG, and GIF images are allowed'
});
return resolve();
}

// Process the file...

res.status(200).json({ message: 'File uploaded successfully' });
return resolve();
});
});
}

Real-World Example: Product API

Let's build a complete example for a product API that shows validation for different HTTP methods:

javascript
// pages/api/products/[id].js
import { z } from 'zod';

// Schema for creating/updating a product
const ProductSchema = z.object({
name: z.string().min(1, "Product name is required"),
description: z.string().optional(),
price: z.number().positive("Price must be positive"),
category: z.string().min(1, "Category is required"),
inStock: z.boolean().default(true)
});

// Schema for query parameters
const QuerySchema = z.object({
id: z.string().optional()
});

export default async function handler(req, res) {
try {
// Validate query parameters (like ID)
const { id } = QuerySchema.parse(req.query);

// Handle different HTTP methods
switch (req.method) {
case 'GET':
// Return product by ID
return res.status(200).json({ /* product data */ });

case 'POST':
// Validate request body for creation
const newProductData = ProductSchema.parse(req.body);
// Create product...
return res.status(201).json({
message: 'Product created successfully',
product: newProductData
});

case 'PUT':
// Validate request body for update
const updatedProductData = ProductSchema.parse(req.body);
// Update product...
return res.status(200).json({
message: 'Product updated successfully',
product: updatedProductData
});

case 'DELETE':
// Delete product...
return res.status(200).json({
message: 'Product deleted successfully'
});

default:
return res.status(405).json({ error: 'Method not allowed' });
}
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}

console.error('API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

Best Practices for API Validation

  1. Validate Everything: Query params, route params, request bodies, and headers when necessary
  2. Use Typescript: Combine schema validation with TypeScript for better type safety
  3. Consistent Error Responses: Make error responses consistent across your API
  4. Sanitize Inputs: Not only validate, but also sanitize inputs to prevent XSS attacks
  5. Rate Limiting: Implement rate limiting to prevent abuse (using packages like express-rate-limit)

Here's how you can enhance validation with sanitization:

javascript
import { z } from 'zod';
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';

const window = new JSDOM('').window;
const purify = DOMPurify(window);

// Create a custom sanitized string type
const sanitizedString = z.string().transform((val) => purify.sanitize(val));

const UserInputSchema = z.object({
comment: sanitizedString.min(1, "Comment is required")
});

Summary

API validation is an essential part of building robust, secure APIs in Next.js. In this guide, we've covered:

  1. Basic validation using JavaScript
  2. Schema-based validation with Zod
  3. Creating reusable validation middleware
  4. Advanced validation techniques for different scenarios
  5. A complete real-world API example with validation
  6. Best practices for API validation

By implementing proper validation in your Next.js API routes, you can ensure data integrity, improve security, and provide better error messages to API consumers.

Additional Resources

Exercises

  1. Create an API endpoint for a blog post with validation for title, content, and tags
  2. Add validation to an API that accepts search parameters and pagination
  3. Build a user registration API with advanced password validation (special characters, numbers, etc.)
  4. Implement file upload validation for different file types and sizes
  5. Create a validation middleware that works with both JSON and form data


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)