Next.js API Routes: A Deep Dive
Next.js API Routes: A Deep Dive
Next.js API Routes enable you to build API endpoints within your Next.js application. This powerful feature allows you to create full-stack applications without a separate backend server.
Introduction to API Routes
Creating Your First API Route
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'
type ResponseData = {
message: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
res.status(200).json({ message: 'Hello from Next.js!' })
}
Dynamic API Routes
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
interface User {
id: string
name: string
email: string
}
const users: User[] = [
{ id: '1', name: 'John Doe', email: 'john@example.com' },
{ id: '2', name: 'Jane Doe', email: 'jane@example.com' },
]
export default function handler(
req: NextApiRequest,
res: NextApiResponse<User | { error: string }>
) {
const { id } = req.query
if (req.method === 'GET') {
const user = users.find(u => u.id === id)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
return res.status(200).json(user)
}
res.setHeader('Allow', ['GET'])
res.status(405).end(`Method ${req.method} Not Allowed`)
}
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
// slug is an array of path segments
// /api/posts/2022/10/my-post -> slug: ['2022', '10', 'my-post']
res.status(200).json({ slug })
}
HTTP Methods
RESTful API Pattern
// pages/api/posts/index.ts
import type { NextApiRequest, NextApiResponse } from 'next'
interface Post {
id: string
title: string
content: string
}
let posts: Post[] = [
{ id: '1', title: 'First Post', content: 'Hello World' },
]
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Post | Post[] | { error: string }>
) {
switch (req.method) {
case 'GET':
return res.status(200).json(posts)
case 'POST': {
const { title, content } = req.body
if (!title || !content) {
return res.status(400).json({ error: 'Missing required fields' })
}
const newPost: Post = {
id: Date.now().toString(),
title,
content,
}
posts.push(newPost)
return res.status(201).json(newPost)
}
default:
res.setHeader('Allow', ['GET', 'POST'])
return res.status(405).end(`Method ${req.method} Not Allowed`)
}
}
Handling PUT and DELETE
// pages/api/posts/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
interface Post {
id: string
title: string
content: string
}
let posts: Post[] = [
{ id: '1', title: 'First Post', content: 'Hello World' },
]
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Post | { error: string }>
) {
const { id } = req.query
const postIndex = posts.findIndex(p => p.id === id)
if (postIndex === -1) {
return res.status(404).json({ error: 'Post not found' })
}
switch (req.method) {
case 'GET':
return res.status(200).json(posts[postIndex])
case 'PUT': {
const { title, content } = req.body
posts[postIndex] = {
...posts[postIndex],
...(title && { title }),
...(content && { content }),
}
return res.status(200).json(posts[postIndex])
}
case 'DELETE':
posts = posts.filter(p => p.id !== id)
return res.status(204).end()
default:
res.setHeader('Allow', ['GET', 'PUT', 'DELETE'])
return res.status(405).end(`Method ${req.method} Not Allowed`)
}
}
Request Handling
Parsing Request Body
// Next.js automatically parses JSON bodies
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
// For JSON content-type, body is already parsed
const { name, email } = req.body
console.log('Parsed body:', { name, email })
res.status(200).json({ received: req.body })
}
}
Handling File Uploads
// pages/api/upload.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import formidable from 'formidable'
import fs from 'fs'
import path from 'path'
export const config = {
api: {
bodyParser: false, // Disable default body parser for file uploads
},
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const form = formidable({
uploadDir: './public/uploads',
keepExtensions: true,
})
try {
const [fields, files] = await form.parse(req)
res.status(200).json({
fields,
files,
})
} catch (error) {
res.status(500).json({ error: 'Error uploading file' })
}
}
Cookies and Headers
// pages/api/auth/login.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { serialize } from 'cookie'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { email, password } = req.body
// Validate credentials
if (email === 'admin@example.com' && password === 'password') {
// Set cookie
res.setHeader('Set-Cookie', serialize('token', 'jwt-token-here', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
}))
return res.status(200).json({ success: true })
}
return res.status(401).json({ error: 'Invalid credentials' })
}
}
Middleware
Custom Middleware
// pages/api/_middleware.ts
import type { NextApiRequest, NextApiResponse } from 'next'
type MiddlewareFunction = (
req: NextApiRequest,
res: NextApiResponse,
next: () => void
) => void
export function runMiddleware(
req: NextApiRequest,
res: NextApiResponse,
middleware: MiddlewareFunction
): Promise<void> {
return new Promise((resolve, reject) => {
middleware(req, res, (result?: Error) => {
if (result instanceof Error) {
return reject(result)
}
return resolve()
})
})
}
// Usage
import Cors from 'cors'
const cors = Cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
})
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
await runMiddleware(req, res, cors)
res.status(200).json({ message: 'CORS enabled' })
}
Authentication Middleware
// lib/auth-middleware.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { verify } from 'jsonwebtoken'
export function authenticate(
req: NextApiRequest,
res: NextApiResponse
): string | null {
const token = req.cookies.token
if (!token) {
res.status(401).json({ error: 'Not authenticated' })
return null
}
try {
const decoded = verify(token, process.env.JWT_SECRET!)
return (decoded as { userId: string }).userId
} catch {
res.status(401).json({ error: 'Invalid token' })
return null
}
}
// Usage
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const userId = authenticate(req, res)
if (!userId) return // Response already sent
// Continue with authenticated request
res.status(200).json({ userId })
}
Database Integration
Prisma Integration
// pages/api/users/index.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
) {
switch (req.method) {
case 'GET': {
const users = await prisma.user.findMany()
return res.status(200).json(users)
}
case 'POST': {
const { name, email } = req.body
const user = await prisma.user.create({
data: { name, email },
})
return res.status(201).json(user)
}
default:
res.setHeader('Allow', ['GET', 'POST'])
return res.status(405).end(`Method ${req.method} Not Allowed`)
}
}
Connection Pooling
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
Error Handling
Centralized Error Handling
// lib/api-utils.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export class ApiError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
export function handleErrors(
handler: (req: NextApiRequest, res: NextApiResponse) => Promise<void>
) {
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
await handler(req, res)
} catch (error) {
console.error('API Error:', error)
if (error instanceof ApiError) {
return res.status(error.statusCode).json({ error: error.message })
}
return res.status(500).json({ error: 'Internal Server Error' })
}
}
}
// Usage
export default handleErrors(async (req, res) => {
if (req.method !== 'GET') {
throw new ApiError(405, 'Method Not Allowed')
}
const users = await fetchUsers()
res.status(200).json(users)
})
Response Helpers
// lib/api-response.ts
import type { NextApiResponse } from 'next'
export function success<T>(res: NextApiResponse, data: T, status = 200) {
return res.status(status).json(data)
}
export function created<T>(res: NextApiResponse, data: T) {
return res.status(201).json(data)
}
export function noContent(res: NextApiResponse) {
return res.status(204).end()
}
export function badRequest(res: NextApiResponse, message: string) {
return res.status(400).json({ error: message })
}
export function unauthorized(res: NextApiResponse, message = 'Unauthorized') {
return res.status(401).json({ error: message })
}
export function notFound(res: NextApiResponse, message = 'Not Found') {
return res.status(404).json({ error: message })
}
Rate Limiting
// pages/api/_rate-limit.ts
import type { NextApiRequest, NextApiResponse } from 'next'
const rateLimit = new Map<string, { count: number; resetTime: number }>()
export function checkRateLimit(
req: NextApiRequest,
res: NextApiResponse,
limit = 10,
windowMs = 60000
): boolean {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown'
const now = Date.now()
const record = rateLimit.get(ip as string)
if (!record || now > record.resetTime) {
rateLimit.set(ip as string, { count: 1, resetTime: now + windowMs })
return true
}
if (record.count >= limit) {
res.status(429).json({ error: 'Too many requests' })
return false
}
record.count++
return true
}
// Usage
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (!checkRateLimit(req, res)) return
res.status(200).json({ message: 'Success' })
}
Testing API Routes
Unit Testing with Jest
// __tests__/api/users.test.ts
import handler from '@/pages/api/users/index'
import { createMocks } from 'node-mocks-http'
describe('/api/users', () => {
it('returns all users on GET', async () => {
const { req, res } = createMocks({
method: 'GET',
})
await handler(req, res)
expect(res._getStatusCode()).toBe(200)
expect(JSON.parse(res._getData())).toBeInstanceOf(Array)
})
it('creates a user on POST', async () => {
const { req, res } = createMocks({
method: 'POST',
body: {
name: 'Test User',
email: 'test@example.com',
},
})
await handler(req, res)
expect(res._getStatusCode()).toBe(201)
const data = JSON.parse(res._getData())
expect(data.name).toBe('Test User')
})
})
Best Practices
1. Input Validation
import { z } from 'zod'
const userSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
})
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
try {
const validated = userSchema.parse(req.body)
// Continue with validated data
} catch (error) {
return res.status(400).json({ error: 'Invalid input', details: error })
}
}
}
2. Environment Variables
// Never expose secrets to client
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = process.env.API_KEY // Server-side only
if (!apiKey) {
console.error('API_KEY not configured')
return res.status(500).json({ error: 'Server configuration error' })
}
}
3. Structured Logging
function logRequest(req: NextApiRequest, statusCode: number, duration: number) {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
method: req.method,
path: req.url,
statusCode,
duration: `${duration}ms`,
}))
}
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const start = Date.now()
// ... handler logic ...
logRequest(req, 200, Date.now() - start)
}
Conclusion
Next.js API Routes provide a powerful way to build full-stack applications. By following these patterns and best practices, you can create robust, scalable APIs within your Next.js application.
Key Takeaways
- Use proper HTTP methods and status codes
- Implement middleware for cross-cutting concerns
- Validate all inputs
- Handle errors gracefully
- Implement rate limiting for public APIs
Start building your APIs with Next.js and enjoy the simplicity of a unified full-stack framework!