Next.js API Routes: Backend in Next.js
Next.js API Routes let you build backend endpoints directly within your Next.js application. Every file you create in the `/pages/api` directory becomes a serverless function with its own endpoint. A...
Key Insights
- Next.js API Routes transform your frontend application into a full-stack solution by providing serverless backend endpoints through file-based routing in the
/pages/apidirectory - Dynamic routing, middleware patterns, and database integration make API Routes powerful enough for production applications, but they come with a 50MB response limit and platform-specific execution timeouts
- While API Routes work well for simple backends and BFF (Backend for Frontend) patterns, complex applications often benefit from dedicated backend services or the newer App Router Route Handlers
Introduction to API Routes
Next.js API Routes let you build backend endpoints directly within your Next.js application. Every file you create in the /pages/api directory becomes a serverless function with its own endpoint. A file at /pages/api/hello.js automatically maps to /api/hello.
This architecture works exceptionally well for:
- Backend-for-Frontend (BFF) patterns where you need to aggregate multiple services
- Simple CRUD operations that don’t justify a separate backend
- Serverless deployments on Vercel, AWS Lambda, or similar platforms
- Prototyping and MVPs where development speed matters
Skip API Routes when you need:
- WebSocket connections or long-running processes
- Complex business logic that benefits from microservices architecture
- Shared backend logic across multiple frontend applications
- Sub-50ms response times (cold starts can add latency)
Here’s the simplest possible API route:
// pages/api/hello.js
export default function handler(req, res) {
res.status(200).json({ message: 'Hello from Next.js API Routes' });
}
Access it at http://localhost:3000/api/hello. That’s it. No server configuration, no routing setup, no Express boilerplate.
Creating Your First API Route
Every API route exports a default function that receives req (request) and res (response) objects. These are enhanced versions of Node.js HTTP objects with additional helper methods.
A practical GET endpoint returning data:
// pages/api/products.js
export default function handler(req, res) {
const products = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 29 },
{ id: 3, name: 'Keyboard', price: 79 }
];
res.status(200).json({ products, count: products.length });
}
Handling POST requests with body parsing:
// pages/api/subscribe.js
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { email } = req.body;
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email required' });
}
// Add to database/mailing list
// await addSubscriber(email);
res.status(201).json({ message: 'Subscribed successfully', email });
}
Next.js automatically parses JSON request bodies, so req.body is ready to use.
Handling multiple HTTP methods in a single route:
// pages/api/settings.js
export default async function handler(req, res) {
switch (req.method) {
case 'GET':
const settings = { theme: 'dark', notifications: true };
return res.status(200).json(settings);
case 'PUT':
const { theme, notifications } = req.body;
// Update settings in database
return res.status(200).json({ message: 'Settings updated' });
case 'DELETE':
// Reset to defaults
return res.status(200).json({ message: 'Settings reset' });
default:
res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
Dynamic API Routes
Dynamic routes use bracket notation to create flexible endpoints. A file named [id].js captures URL segments as parameters.
RESTful user API with dynamic routing:
// pages/api/users/[id].js
export default async function handler(req, res) {
const { id } = req.query;
switch (req.method) {
case 'GET':
// Fetch user from database
const user = { id, name: 'John Doe', email: 'john@example.com' };
return res.status(200).json(user);
case 'PUT':
const updates = req.body;
// Update user in database
return res.status(200).json({ message: `User ${id} updated`, updates });
case 'DELETE':
// Delete user from database
return res.status(200).json({ message: `User ${id} deleted` });
default:
res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
Catch-all routes capture multiple segments:
// pages/api/files/[...path].js
export default function handler(req, res) {
const { path } = req.query;
// path is an array: /api/files/docs/2024/report.pdf
// path = ['docs', '2024', 'report.pdf']
const filePath = path.join('/');
res.status(200).json({ requestedFile: filePath });
}
Middleware and Request Handling
Production API routes need authentication, validation, and error handling. Create reusable middleware patterns:
// lib/middleware/auth.js
export function withAuth(handler) {
return async (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
// Verify token (use JWT, session, etc.)
req.user = { id: 1, email: 'user@example.com' };
return handler(req, res);
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
};
}
Error handling wrapper:
// lib/middleware/errorHandler.js
export function withErrorHandler(handler) {
return async (req, res) => {
try {
return await handler(req, res);
} catch (error) {
console.error('API Error:', error);
if (error.name === 'ValidationError') {
return res.status(400).json({ error: error.message });
}
return res.status(500).json({ error: 'Internal server error' });
}
};
}
Input validation with Zod:
// pages/api/posts.js
import { z } from 'zod';
import { withAuth } from '@/lib/middleware/auth';
import { withErrorHandler } from '@/lib/middleware/errorHandler';
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional()
});
async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const validatedData = createPostSchema.parse(req.body);
// Create post in database
const post = { id: 123, ...validatedData, authorId: req.user.id };
res.status(201).json(post);
}
export default withErrorHandler(withAuth(handler));
Database Integration
API Routes support any Node.js database client. Here’s a Prisma example:
// lib/prisma.js
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global;
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
Using Prisma in an API route:
// pages/api/posts/index.js
import { prisma } from '@/lib/prisma';
export default async function handler(req, res) {
if (req.method === 'GET') {
const posts = await prisma.post.findMany({
include: { author: true },
orderBy: { createdAt: 'desc' },
take: 20
});
return res.status(200).json(posts);
}
if (req.method === 'POST') {
const { title, content, authorId } = req.body;
const post = await prisma.post.create({
data: { title, content, authorId }
});
return res.status(201).json(post);
}
res.status(405).json({ error: 'Method not allowed' });
}
Connection pooling happens automatically with this singleton pattern. Never instantiate database clients inside handler functions.
Advanced Patterns and Best Practices
Organize related endpoints with helper functions:
// lib/api/users.js
import { prisma } from '@/lib/prisma';
export async function getUser(id) {
return prisma.user.findUnique({ where: { id: parseInt(id) } });
}
export async function updateUser(id, data) {
return prisma.user.update({
where: { id: parseInt(id) },
data
});
}
// pages/api/users/[id].js
import { getUser, updateUser } from '@/lib/api/users';
export default async function handler(req, res) {
const { id } = req.query;
if (req.method === 'GET') {
const user = await getUser(id);
return res.status(200).json(user);
}
if (req.method === 'PUT') {
const user = await updateUser(id, req.body);
return res.status(200).json(user);
}
}
Response caching with headers:
// pages/api/public-data.js
export default async function handler(req, res) {
const data = { timestamp: Date.now() };
// Cache for 5 minutes
res.setHeader('Cache-Control', 's-maxage=300, stale-while-revalidate');
res.status(200).json(data);
}
Environment-based configuration:
// lib/config.js
export const config = {
isDevelopment: process.env.NODE_ENV === 'development',
apiUrl: process.env.NEXT_PUBLIC_API_URL,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET
};
Limitations and Alternatives
API Routes have constraints:
- 50MB response limit on Vercel (platform-dependent elsewhere)
- Execution timeouts: 10s on Hobby, 60s on Pro (Vercel)
- No WebSockets or streaming (use dedicated servers)
- Cold starts add latency on serverless platforms
For Next.js 13+ with App Router, use Route Handlers instead:
// app/api/users/route.ts (App Router)
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const users = [{ id: 1, name: 'John' }];
return NextResponse.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
return NextResponse.json({ created: true }, { status: 201 });
}
Route Handlers offer better TypeScript support, Web API standards (Request/Response), and streaming capabilities. They’re the future of Next.js backend development.
Use dedicated backend services when you need complex business logic, microservices architecture, or shared APIs across multiple frontends. API Routes excel at BFF patterns, simple CRUD operations, and rapid prototyping. Choose the right tool for your scale and complexity.