Skip to main content

Next.js API Error Handling

When building web applications with Next.js, properly handling errors in your API routes is crucial for creating reliable, maintainable, and user-friendly experiences. Good error handling helps both users and developers understand what went wrong and how to fix it.

Introduction to API Error Handling

Next.js API routes provide a straightforward way to build serverless API endpoints within your Next.js application. However, without proper error handling, these endpoints can fail in unpredictable ways, leading to poor user experiences and difficult debugging.

In this guide, we'll explore how to implement robust error handling in Next.js API routes, covering:

  • Basic error handling patterns
  • Custom error classes
  • HTTP status codes
  • Middleware-based error handling
  • Validation errors
  • Logging and monitoring

Basic Error Handling in Next.js API Routes

At its simplest, error handling in Next.js API routes involves using try/catch blocks and returning appropriate error responses.

Simple Example

javascript
// pages/api/users/[id].js
export default async function handler(req, res) {
try {
const { id } = req.query;

// Simulate database lookup
if (id === '999') {
throw new Error('User not found');
}

// Success case
res.status(200).json({ id, name: 'John Doe' });
} catch (error) {
console.error('API error:', error);
res.status(500).json({ error: 'Something went wrong' });
}
}

When you call this API with an ID of 999 (e.g., /api/users/999), it will return:

json
{ "error": "Something went wrong" }

with a 500 status code.

Improving Error Responses

While the above example works, it's better to provide specific error messages and appropriate status codes:

javascript
// pages/api/users/[id].js
export default async function handler(req, res) {
try {
const { id } = req.query;

// Validate input
if (!id) {
return res.status(400).json({ error: 'Missing user ID' });
}

// Simulate database lookup
if (id === '999') {
return res.status(404).json({ error: 'User not found' });
}

// Success case
res.status(200).json({ id, name: 'John Doe' });
} catch (error) {
console.error('API error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}

Notice how we're now using:

  • Status code 400 for client errors like missing parameters
  • Status code 404 for not found resources
  • Status code 500 for server errors

Custom Error Classes

For more sophisticated applications, custom error classes can help organize and standardize error handling:

javascript
// lib/errors.js
export class ApiError extends Error {
constructor(statusCode, message, details = null) {
super(message);
this.statusCode = statusCode;
this.details = details;
this.name = 'ApiError';
}
}

export class NotFoundError extends ApiError {
constructor(message = 'Resource not found', details = null) {
super(404, message, details);
this.name = 'NotFoundError';
}
}

export class ValidationError extends ApiError {
constructor(message = 'Validation failed', details = null) {
super(400, message, details);
this.name = 'ValidationError';
}
}

You can then use these custom errors in your API routes:

javascript
// pages/api/users/[id].js
import { NotFoundError, ValidationError } from '../../../lib/errors';

export default async function handler(req, res) {
try {
const { id } = req.query;

if (!id) {
throw new ValidationError('Missing user ID');
}

// Simulate database lookup
if (id === '999') {
throw new NotFoundError('User not found', { userId: id });
}

// Success case
res.status(200).json({ id, name: 'John Doe' });
} catch (error) {
console.error('API error:', error);

// Handle known errors
if (error.name === 'ApiError') {
return res.status(error.statusCode).json({
error: error.message,
details: error.details
});
}

// Handle unknown errors
res.status(500).json({ error: 'Internal server error' });
}
}

Creating a Centralized Error Handler

For larger applications, you might want to create a centralized error handling wrapper:

javascript
// lib/apiHandler.js
import { ApiError } from './errors';

export function withErrorHandling(handler) {
return async (req, res) => {
try {
return await handler(req, res);
} catch (error) {
console.error('API error:', error);

// Handle known errors
if (error instanceof ApiError) {
return res.status(error.statusCode).json({
error: error.message,
details: error.details
});
}

// Handle unknown errors
res.status(500).json({
error: 'Internal server error',
// In development, include error message for easier debugging
...(process.env.NODE_ENV === 'development' && {
message: error.message,
stack: error.stack
})
});
}
};
}

Now you can use this wrapper around your API handlers:

javascript
// pages/api/users/[id].js
import { withErrorHandling } from '../../../lib/apiHandler';
import { NotFoundError, ValidationError } from '../../../lib/errors';

async function getUserHandler(req, res) {
const { id } = req.query;

if (!id) {
throw new ValidationError('Missing user ID');
}

// Simulate database lookup
if (id === '999') {
throw new NotFoundError('User not found', { userId: id });
}

// Success case
res.status(200).json({ id, name: 'John Doe' });
}

export default withErrorHandling(getUserHandler);

Input Validation with Zod

For robust input validation, you can use libraries like Zod. Here's how to integrate it with our error handling:

javascript
// pages/api/create-user.js
import { z } from 'zod';
import { withErrorHandling } from '../../lib/apiHandler';
import { ValidationError } from '../../lib/errors';

// Define validation schema
const userSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().positive().optional()
});

async function createUserHandler(req, res) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST']);
throw new ValidationError(`Method ${req.method} not allowed`);
}

try {
// Validate request body
const userData = userSchema.parse(req.body);

// Process the validated data
// ... database operations here

res.status(201).json({
message: 'User created successfully',
user: userData
});
} catch (error) {
if (error instanceof z.ZodError) {
throw new ValidationError('Invalid user data', error.format());
}
throw error; // Re-throw other errors
}
}

export default withErrorHandling(createUserHandler);

Error Handling with Database Operations

Let's see a practical example involving database operations (using Prisma as an example):

javascript
// pages/api/posts/[id].js
import { withErrorHandling } from '../../../lib/apiHandler';
import { NotFoundError, ValidationError } from '../../../lib/errors';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function getPostHandler(req, res) {
const { id } = req.query;

if (!id || isNaN(Number(id))) {
throw new ValidationError('Invalid post ID');
}

try {
const post = await prisma.post.findUnique({
where: { id: Number(id) },
include: { author: true }
});

if (!post) {
throw new NotFoundError('Post not found', { postId: id });
}

res.status(200).json(post);
} catch (error) {
// Handle Prisma-specific errors
if (error.code === 'P2002') {
throw new ValidationError('Unique constraint violation', {
fields: error.meta?.target
});
}

// Re-throw other errors to be handled by the wrapper
throw error;
} finally {
// Clean up resources
await prisma.$disconnect();
}
}

export default withErrorHandling(getPostHandler);

Authentication and Authorization Errors

When building APIs that require authentication, you'll need specific error handling:

javascript
// lib/errors.js (additional error classes)
export class UnauthorizedError extends ApiError {
constructor(message = 'Unauthorized access', details = null) {
super(401, message, details);
this.name = 'UnauthorizedError';
}
}

export class ForbiddenError extends ApiError {
constructor(message = 'Access forbidden', details = null) {
super(403, message, details);
this.name = 'ForbiddenError';
}
}

Example usage:

javascript
// pages/api/admin/settings.js
import { withErrorHandling } from '../../../lib/apiHandler';
import { UnauthorizedError, ForbiddenError } from '../../../lib/errors';

async function adminSettingsHandler(req, res) {
// Check if user is authenticated
const user = req.session?.user;
if (!user) {
throw new UnauthorizedError('You must be logged in');
}

// Check if user has admin role
if (user.role !== 'ADMIN') {
throw new ForbiddenError('Admin access required');
}

// Handle the admin request
res.status(200).json({ settings: { /* admin settings */ } });
}

export default withErrorHandling(adminSettingsHandler);

Rate Limiting Errors

For APIs that implement rate limiting, you'll want to return 429 (Too Many Requests) status codes:

javascript
// lib/errors.js (additional error class)
export class TooManyRequestsError extends ApiError {
constructor(message = 'Rate limit exceeded', details = null) {
super(429, message, details);
this.name = 'TooManyRequestsError';
}
}

Example with a simple rate limiter:

javascript
// pages/api/limited-endpoint.js
import { withErrorHandling } from '../../lib/apiHandler';
import { TooManyRequestsError } from '../../lib/errors';

// Simple in-memory rate limiting (use Redis for production)
const RATE_LIMIT = 5;
const WINDOW_MS = 60000; // 1 minute
const ipRequests = {};

async function limitedEndpointHandler(req, res) {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;

// Create or update tracking for this IP
if (!ipRequests[ip]) {
ipRequests[ip] = { count: 0, resetAt: Date.now() + WINDOW_MS };

// Cleanup after window expires
setTimeout(() => delete ipRequests[ip], WINDOW_MS);
}

// Reset if window expired
if (ipRequests[ip].resetAt < Date.now()) {
ipRequests[ip] = { count: 0, resetAt: Date.now() + WINDOW_MS };
}

// Increment request count
ipRequests[ip].count++;

// Check if rate limit exceeded
if (ipRequests[ip].count > RATE_LIMIT) {
const retryAfter = Math.ceil((ipRequests[ip].resetAt - Date.now()) / 1000);
res.setHeader('Retry-After', retryAfter);
throw new TooManyRequestsError('Rate limit exceeded', {
retryAfterSeconds: retryAfter
});
}

// Handle the actual request
res.status(200).json({ message: 'Success' });
}

export default withErrorHandling(limitedEndpointHandler);

Error Logging and Monitoring

For production applications, you'll want to log errors to a monitoring service like Sentry:

javascript
// lib/apiHandler.js (with Sentry)
import * as Sentry from '@sentry/nextjs';
import { ApiError } from './errors';

export function withErrorHandling(handler) {
return async (req, res) => {
try {
return await handler(req, res);
} catch (error) {
// Log all errors to console
console.error('API error:', error);

// Report error to Sentry
Sentry.captureException(error, {
extra: {
route: req.url,
method: req.method,
query: req.query,
// Don't include full body to avoid capturing sensitive data
bodyKeys: Object.keys(req.body || {})
}
});

// Handle known errors
if (error instanceof ApiError) {
return res.status(error.statusCode).json({
error: error.message,
details: error.details
});
}

// Handle unknown errors
res.status(500).json({ error: 'Internal server error' });
}
};
}

Best Practices for API Error Handling

  1. Use appropriate HTTP status codes:

    • 400: Bad request (client error)
    • 401: Unauthorized (authentication required)
    • 403: Forbidden (authenticated but insufficient permissions)
    • 404: Not found
    • 422: Unprocessable entity (validation error)
    • 429: Too many requests (rate limiting)
    • 500: Internal server error (unexpected server error)
  2. Provide helpful error messages that explain what went wrong

  3. Include error details when appropriate, but be careful not to expose sensitive information

  4. Log all errors for debugging and monitoring purposes

  5. Handle both expected and unexpected errors

  6. Validate input before processing it

  7. Sanitize error outputs in production environments

Real-World Example: Complete API Route

Here's a complete example of a product API with comprehensive error handling:

javascript
// pages/api/products/index.js
import { withErrorHandling } from '../../../lib/apiHandler';
import { ValidationError, UnauthorizedError } from '../../../lib/errors';
import { z } from 'zod';
import { PrismaClient } from '@prisma/client';
import { verifyToken } from '../../../lib/auth';

const prisma = new PrismaClient();

// Schema for product creation
const createProductSchema = z.object({
name: z.string().min(3).max(100),
description: z.string().max(500).optional(),
price: z.number().positive(),
categoryId: z.number().int().positive()
});

async function productsHandler(req, res) {
// Handle different HTTP methods
switch (req.method) {
case 'GET':
return getProducts(req, res);
case 'POST':
return createProduct(req, res);
default:
res.setHeader('Allow', ['GET', 'POST']);
throw new ValidationError(`Method ${req.method} not allowed`);
}
}

// Get all products with optional filtering
async function getProducts(req, res) {
const { category, minPrice, maxPrice, search } = req.query;

const filters = {};

if (category) {
filters.categoryId = Number(category);
}

if (minPrice || maxPrice) {
filters.price = {};
if (minPrice) filters.price.gte = Number(minPrice);
if (maxPrice) filters.price.lte = Number(maxPrice);
}

if (search) {
filters.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }
];
}

try {
const products = await prisma.product.findMany({
where: filters,
include: {
category: true
}
});

res.status(200).json(products);
} finally {
await prisma.$disconnect();
}
}

// Create a new product (requires authentication)
async function createProduct(req, res) {
// Verify authentication
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
throw new UnauthorizedError('Authentication required');
}

let userId;
try {
const decoded = verifyToken(token);
userId = decoded.userId;
} catch (error) {
throw new UnauthorizedError('Invalid token');
}

// Validate request body
let productData;
try {
productData = createProductSchema.parse(req.body);
} catch (error) {
if (error instanceof z.ZodError) {
throw new ValidationError('Invalid product data', error.format());
}
throw error;
}

try {
// Check if category exists
const category = await prisma.category.findUnique({
where: { id: productData.categoryId }
});

if (!category) {
throw new ValidationError('Invalid category', {
categoryId: 'Category does not exist'
});
}

// Create the product
const product = await prisma.product.create({
data: {
...productData,
createdById: userId
},
include: {
category: true
}
});

res.status(201).json(product);
} finally {
await prisma.$disconnect();
}
}

export default withErrorHandling(productsHandler);

Summary

Effective error handling in Next.js API routes is essential for building robust, user-friendly applications. Here's what we covered:

  1. Basic error handling with try/catch blocks and status codes
  2. Custom error classes for different error types
  3. Centralized error handling with wrapper functions
  4. Input validation using Zod
  5. Database error handling
  6. Authentication and authorization errors
  7. Rate limiting implementation
  8. Error logging and monitoring with Sentry
  9. Best practices for API error handling
  10. Real-world example of a complete API route

By implementing these patterns, your Next.js API routes will be more reliable, easier to debug, and provide better experiences for your users.

Additional Resources

Exercises

  1. Create a custom error handler that formats errors differently based on whether the application is running in development or production mode.

  2. Implement validation for an API route that accepts user registration data (name, email, password).

  3. Add rate limiting to an authentication endpoint to prevent brute force attacks.

  4. Build a middleware that checks authentication for multiple API routes.

  5. Implement error handling for file uploads, including validation for file size, type, and upload errors.

Happy coding!



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