Next.js API Routes provide a powerful solution for building API endpoints as serverless functions. With API Routes, you can build your entire backend logic within the same Next.js application that serves your frontend, eliminating the need for a separate backend server. This approach simplifies deployment and reduces infrastructure complexity.
Understanding API Routes
API Routes are files that live in the pages/api directory (for Pages Router) or route.ts files (for App Router). Each file becomes an API endpoint with the file's path. For example, pages/api/users.ts maps to /api/users.
Basic API Route
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = {
name: string;
message: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
res.status(200).json({
name: 'John Doe',
message: 'Hello from Next.js API Routes!',
});
}This simple API route responds to GET requests with JSON data. The handler function receives the request and response objects.
HTTP Methods
Handle different HTTP methods in a single API route:
// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { method } = req;
switch (method) {
case 'GET':
try {
// Fetch users from database
const users = await fetchUsers();
res.status(200).json(users);
} catch (error) {
res.status(500).json({ message: 'Error fetching users' });
}
break;
case 'POST':
try {
// Create new user
const user = await createUser(req.body);
res.status(201).json(user);
} catch (error) {
res.status(500).json({ message: 'Error creating user' });
}
break;
default:
res.setHeader('Allow', ['GET', 'POST']);
res.status(405).end(`Method ${method} Not Allowed`);
}
}
async function fetchUsers() {
// Database logic here
return [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
];
}
async function createUser(userData: any) {
// Create user logic here
return {
id: Date.now(),
...userData,
createdAt: new Date(),
};
}This pattern handles GET and POST requests in a single endpoint, returning appropriate status codes.
Route Handlers
API routes can be organized into dynamic routes and catch-all routes.
Dynamic Routes
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query;
if (req.method === 'GET') {
try {
const user = await fetchUser(id as string);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json(user);
} catch (error) {
res.status(500).json({ message: 'Error fetching user' });
}
} else if (req.method === 'PUT') {
try {
const user = await updateUser(id as string, req.body);
res.status(200).json(user);
} catch (error) {
res.status(500).json({ message: 'Error updating user' });
}
} else if (req.method === 'DELETE') {
try {
await deleteUser(id as string);
res.status(204).end();
} catch (error) {
res.status(500).json({ message: 'Error deleting user' });
}
} else {
res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}Dynamic routes use square brackets to capture path parameters.
Catch-all Routes
// pages/api/posts/[...slug].ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { slug } = req.query;
if (typeof slug === 'string') {
// Handle single slug
res.status(200).json({ slug });
} else if (Array.isArray(slug)) {
// Handle multiple slugs
res.status(200).json({ slug: slug.join('/') });
}
res.status(200).json({ slug });
}Catch-all routes match any path, useful for implementing custom routing logic.
Authentication
Implement authentication in API routes using various strategies.
JWT Authentication
// pages/api/auth/login.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).end('Method Not Allowed');
}
const { email, password } = req.body;
try {
// Find user in database
const user = await findUserByEmail(email);
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Verify password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Generate JWT
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET!,
{ expiresIn: '7d' }
);
res.status(200).json({ token, user: { id: user.id, email: user.email } });
} catch (error) {
res.status(500).json({ message: 'Internal server error' });
}
}
// pages/api/auth/protected.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import jwt from 'jsonwebtoken';
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ message: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
res.status(200).json({ user: decoded });
} catch (error) {
res.status(401).json({ message: 'Invalid token' });
}
}JWT tokens provide stateless authentication for API routes.
Middleware
Use middleware to intercept requests and add custom logic.
Custom Middleware
// pages/api/middleware.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import Cors from 'cors';
// Initialize CORS middleware
const cors = Cors({
methods: ['GET', 'POST', 'PUT', 'DELETE'],
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
});
// Helper method to wait for middleware to execute
function runMiddleware(
req: NextApiRequest,
res: NextApiResponse,
fn: Function
) {
return new Promise((resolve, reject) => {
fn(req, res, (result: any) => {
if (result instanceof Error) {
return reject(result);
}
return resolve(result);
});
});
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
await runMiddleware(req, res, cors);
// Your API logic here
res.status(200).json({ message: 'CORS enabled' });
}Middleware can handle CORS, logging, rate limiting, and more.
Rate Limiting
// lib/rate-limit.ts
import { NextApiRequest, NextApiResponse } from 'next';
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
export function rateLimit(
req: NextApiRequest,
res: NextApiResponse,
limit: number = 100,
windowMs: number = 60000
): boolean {
const ip = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || 'unknown';
const now = Date.now();
if (!rateLimitMap.has(ip)) {
rateLimitMap.set(ip, { count: 1, resetTime: now + windowMs });
return true;
}
const record = rateLimitMap.get(ip)!;
if (now > record.resetTime) {
record.count = 1;
record.resetTime = now + windowMs;
return true;
}
if (record.count >= limit) {
const resetTime = Math.ceil((record.resetTime - now) / 1000);
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', 0);
res.setHeader('X-RateLimit-Reset', resetTime);
return false;
}
record.count++;
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', limit - record.count);
res.setHeader('X-RateLimit-Reset', resetTime);
return true;
}
// pages/api/rate-limited.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { rateLimit } from '@/lib/rate-limit';
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (!rateLimit(req, res, 10, 60000)) {
return res.status(429).json({ message: 'Too many requests' });
}
res.status(200).json({ message: 'Request successful' });
}Rate limiting prevents abuse and ensures fair resource usage.
Database Integration
Connect API routes to various databases.
Using Prisma
// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'GET') {
try {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
res.status(200).json(users);
} catch (error) {
res.status(500).json({ message: 'Error fetching users' });
}
} else if (req.method === 'POST') {
try {
const user = await prisma.user.create({
data: req.body,
});
res.status(201).json(user);
} catch (error) {
res.status(500).json({ message: 'Error creating user' });
}
}
}Prisma provides a type-safe database client for API routes.
Using MongoDB
// pages/api/posts.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { MongoClient, Db } from 'mongodb';
let db: Db;
async function connectToDatabase() {
if (db) return db;
const client = new MongoClient(process.env.MONGODB_URI!);
await client.connect();
db = client.db('blog');
return db;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const db = await connectToDatabase();
if (req.method === 'GET') {
const posts = await db.collection('posts').find({}).toArray();
res.status(200).json(posts);
} else if (req.method === 'POST') {
const post = await db.collection('posts').insertOne(req.body);
res.status(201).json(post);
}
}MongoDB connection can be managed in API routes.
Validation
Validate request data before processing.
Using Zod
// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
age: z.number().min(18).optional(),
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'POST') {
try {
const validatedData = createUserSchema.parse(req.body);
// Create user with validated data
const user = await createUser(validatedData);
res.status(201).json(user);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ errors: error.errors });
}
res.status(500).json({ message: 'Internal server error' });
}
}
}Zod provides runtime type validation with TypeScript support.
Error Handling
Implement proper error handling in API routes.
Error Handler
// lib/api-error.ts
export class ApiError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
export function handleApiError(error: unknown, res: NextApiResponse) {
console.error('API Error:', error);
if (error instanceof ApiError) {
return res.status(error.statusCode).json({
message: error.message,
});
}
if (error instanceof Error) {
return res.status(500).json({
message: process.env.NODE_ENV === 'production'
? 'Internal server error'
: error.message,
});
}
res.status(500).json({
message: 'An unknown error occurred',
});
}
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { ApiError, handleApiError } from '@/lib/api-error';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query;
try {
if (req.method === 'GET') {
const user = await fetchUser(id as string);
if (!user) {
throw new ApiError(404, 'User not found');
}
res.status(200).json(user);
}
} catch (error) {
handleApiError(error, res);
}
}Proper error handling improves debugging and user experience.
File Uploads
Handle file uploads in API routes.
// pages/api/upload.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import formidable from 'formidable';
import fs from 'fs/promises';
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).end('Method Not Allowed');
}
const form = formidable({
uploadDir: '/tmp',
keepExtensions: true,
});
try {
const [fields, files] = await form.parse(req);
const file = files.file?.[0];
if (!file) {
return res.status(400).json({ message: 'No file uploaded' });
}
// Move file to permanent location
const newPath = `/uploads/${file.newFilename}`;
await fs.rename(file.filepath, `./public${newPath}`);
res.status(200).json({
path: newPath,
name: file.originalFilename,
size: file.size,
});
} catch (error) {
res.status(500).json({ message: 'Error uploading file' });
}
}File uploads require special handling in API routes.
Conclusion
Next.js API Routes provide a powerful and flexible solution for building serverless functions. They eliminate the need for a separate backend server while giving you full control over your API logic. By mastering API routes, you can build complete full-stack applications with Next.js, from authentication to database operations to file uploads.
Whether you're building a simple API or a complex backend, Next.js API Routes have you covered. Combine them with middleware, validation, and proper error handling to build production-ready APIs that scale effortlessly.