Next.js API Routes
Next.js API routes allow you to create backend API endpoints within the same project without needing a separate server. This chapter will detail how to create and use API routes.
API Routes Basics
App Router API Routes
In Next.js 13+ App Router, API routes use route.ts files:
typescript
// app/api/hello/route.ts
export async function GET() {
return Response.json({ message: 'Hello, World!' })
}
export async function POST(request: Request) {
const body = await request.json()
return Response.json({ received: body })
}Pages Router API Routes
In the traditional Pages Router:
typescript
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'
type Data = {
message: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
if (req.method === 'GET') {
res.status(200).json({ message: 'Hello, World!' })
} else {
res.setHeader('Allow', ['GET'])
res.status(405).end(`Method ${req.method} Not Allowed`)
}
}HTTP Method Handling
App Router Method Handling
typescript
// app/api/users/route.ts
import { NextRequest } from 'next/server'
// GET request - fetch user list
export async function GET() {
const users = await fetchUsers()
return Response.json(users)
}
// POST request - create new user
export async function POST(request: NextRequest) {
const userData = await request.json()
const newUser = await createUser(userData)
return Response.json(newUser, { status: 201 })
}
// PUT request - update user
export async function PUT(request: NextRequest) {
const userData = await request.json()
const updatedUser = await updateUser(userData)
return Response.json(updatedUser)
}
// DELETE request - delete user
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) {
return Response.json({ error: 'Missing user ID' }, { status: 400 })
}
await deleteUser(id)
return Response.json({ message: 'User deleted' })
}Pages Router Method Handling
typescript
// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
switch (req.method) {
case 'GET':
const users = await fetchUsers()
res.status(200).json(users)
break
case 'POST':
const newUser = await createUser(req.body)
res.status(201).json(newUser)
break
case 'PUT':
const updatedUser = await updateUser(req.body)
res.status(200).json(updatedUser)
break
case 'DELETE':
await deleteUser(req.query.id as string)
res.status(200).json({ message: 'User deleted' })
break
default:
res.setHeader('Allow', ['GET', 'POST', 'PUT', 'DELETE'])
res.status(405).end(`Method ${req.method} Not Allowed`)
}
}Dynamic API Routes
App Router Dynamic Routes
typescript
// app/api/users/[id]/route.ts
import { NextRequest } from 'next/server'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await fetchUser(params.id)
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 })
}
return Response.json(user)
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const userData = await request.json()
const updatedUser = await updateUser(params.id, userData)
return Response.json(updatedUser)
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
await deleteUser(params.id)
return Response.json({ message: 'User deleted' })
}Pages Router Dynamic Routes
typescript
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query
switch (req.method) {
case 'GET':
const user = await fetchUser(id as string)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
res.status(200).json(user)
break
case 'PUT':
const updatedUser = await updateUser(id as string, req.body)
res.status(200).json(updatedUser)
break
case 'DELETE':
await deleteUser(id as string)
res.status(200).json({ message: 'User deleted' })
break
default:
res.setHeader('Allow', ['GET', 'PUT', 'DELETE'])
res.status(405).end(`Method ${req.method} Not Allowed`)
}
}Request and Response Handling
Handling Request Body
typescript
// app/api/posts/route.ts
export async function POST(request: Request) {
try {
// JSON data
const data = await request.json()
// Form data
// const formData = await request.formData()
// Text data
// const text = await request.text()
const post = await createPost(data)
return Response.json(post, { status: 201 })
} catch (error) {
return Response.json(
{ error: 'Invalid request data' },
{ status: 400 }
)
}
}Handling Query Parameters
typescript
// app/api/search/route.ts
import { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q')
const page = searchParams.get('page') || '1'
const limit = searchParams.get('limit') || '10'
if (!query) {
return Response.json(
{ error: 'Missing query parameter' },
{ status: 400 }
)
}
const results = await searchPosts({
query,
page: parseInt(page),
limit: parseInt(limit)
})
return Response.json(results)
}Setting Response Headers
typescript
// app/api/data/route.ts
export async function GET() {
const data = await fetchData()
return Response.json(data, {
status: 200,
headers: {
'Cache-Control': 'public, max-age=3600',
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value'
}
})
}Middleware and Authentication
Authentication Middleware
typescript
// lib/auth.ts
import jwt from 'jsonwebtoken'
export function verifyToken(token: string) {
try {
return jwt.verify(token, process.env.JWT_SECRET!)
} catch (error) {
return null
}
}
export function extractToken(request: Request): string | null {
const authHeader = request.headers.get('authorization')
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7)
}
return null
}Protected API Routes
typescript
// app/api/protected/route.ts
import { NextRequest } from 'next/server'
import { verifyToken, extractToken } from '@/lib/auth'
export async function GET(request: NextRequest) {
const token = extractToken(request)
if (!token) {
return Response.json(
{ error: 'Missing authentication token' },
{ status: 401 }
)
}
const user = verifyToken(token)
if (!user) {
return Response.json(
{ error: 'Invalid token' },
{ status: 401 }
)
}
// Return protected data
const data = await fetchProtectedData(user.id)
return Response.json(data)
}File Upload
Handling File Uploads
typescript
// app/api/upload/route.ts
import { NextRequest } from 'next/server'
import { writeFile } from 'fs/promises'
import path from 'path'
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return Response.json(
{ error: 'No file found' },
{ status: 400 }
)
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
if (!allowedTypes.includes(file.type)) {
return Response.json(
{ error: 'Unsupported file type' },
{ status: 400 }
)
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
return Response.json(
{ error: 'File too large' },
{ status: 400 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Generate unique filename
const filename = `${Date.now()}-${file.name}`
const filepath = path.join(process.cwd(), 'public/uploads', filename)
await writeFile(filepath, buffer)
return Response.json({
message: 'File uploaded successfully',
filename,
url: `/uploads/${filename}`
})
} catch (error) {
return Response.json(
{ error: 'File upload failed' },
{ status: 500 }
)
}
}Database Integration
Using Prisma
typescript
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prismatypescript
// app/api/users/route.ts
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true
}
})
return Response.json(users)
} catch (error) {
return Response.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
)
}
}
export async function POST(request: Request) {
try {
const { name, email } = await request.json()
const user = await prisma.user.create({
data: { name, email }
})
return Response.json(user, { status: 201 })
} catch (error) {
return Response.json(
{ error: 'Failed to create user' },
{ status: 500 }
)
}
}Error Handling
Unified Error Handling
typescript
// lib/api-error.ts
export class ApiError extends Error {
constructor(
public message: string,
public statusCode: number = 500
) {
super(message)
this.name = 'ApiError'
}
}
export function handleApiError(error: unknown) {
if (error instanceof ApiError) {
return Response.json(
{ error: error.message },
{ status: error.statusCode }
)
}
console.error('API Error:', error)
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}typescript
// app/api/users/[id]/route.ts
import { ApiError, handleApiError } from '@/lib/api-error'
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const user = await prisma.user.findUnique({
where: { id: params.id }
})
if (!user) {
throw new ApiError('User not found', 404)
}
return Response.json(user)
} catch (error) {
return handleApiError(error)
}
}Data Validation
Using Zod Validation
typescript
// lib/validations.ts
import { z } from 'zod'
export const createUserSchema = z.object({
name: z.string().min(1, 'Name cannot be empty').max(50, 'Name too long'),
email: z.string().email('Invalid email format'),
age: z.number().min(0, 'Age cannot be negative').max(120, 'Invalid age')
})
export type CreateUserInput = z.infer<typeof createUserSchema>typescript
// app/api/users/route.ts
import { createUserSchema } from '@/lib/validations'
export async function POST(request: Request) {
try {
const body = await request.json()
const validatedData = createUserSchema.parse(body)
const user = await prisma.user.create({
data: validatedData
})
return Response.json(user, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json(
{ error: 'Data validation failed', details: error.errors },
{ status: 400 }
)
}
return handleApiError(error)
}
}Caching Strategies
Response Caching
typescript
// app/api/posts/route.ts
export async function GET() {
const posts = await fetchPosts()
return Response.json(posts, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300'
}
})
}Using Next.js Cache
typescript
// app/api/data/route.ts
import { unstable_cache } from 'next/cache'
const getCachedData = unstable_cache(
async () => {
return await fetchExpensiveData()
},
['expensive-data'],
{ revalidate: 3600 } // 1 hour
)
export async function GET() {
const data = await getCachedData()
return Response.json(data)
}Testing API Routes
Unit Testing
typescript
// __tests__/api/users.test.ts
import { GET, POST } from '@/app/api/users/route'
import { NextRequest } from 'next/server'
describe('/api/users', () => {
it('should return users list', async () => {
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(Array.isArray(data)).toBe(true)
})
it('should create a new user', async () => {
const userData = {
name: 'Test User',
email: 'test@example.com'
}
const request = new NextRequest('http://localhost:3000/api/users', {
method: 'POST',
body: JSON.stringify(userData),
headers: { 'Content-Type': 'application/json' }
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.name).toBe(userData.name)
expect(data.email).toBe(userData.email)
})
})Best Practices
1. Use TypeScript
typescript
interface User {
id: string
name: string
email: string
}
interface ApiResponse<T> {
data?: T
error?: string
message?: string
}2. Unified Response Format
typescript
// lib/api-response.ts
export function successResponse<T>(data: T, message?: string) {
return Response.json({
success: true,
data,
message
})
}
export function errorResponse(error: string, statusCode: number = 400) {
return Response.json({
success: false,
error
}, { status: statusCode })
}3. Environment Variable Management
typescript
// lib/env.ts
export const env = {
DATABASE_URL: process.env.DATABASE_URL!,
JWT_SECRET: process.env.JWT_SECRET!,
API_KEY: process.env.API_KEY!,
}
// Validate required environment variables
Object.entries(env).forEach(([key, value]) => {
if (!value) {
throw new Error(`Missing required environment variable: ${key}`)
}
})Summary
Next.js API routes provide powerful backend capabilities:
- Easy to use - File system based routing
- Full-stack development - Frontend and backend in the same project
- Type safety - Full TypeScript support
- Performance optimization - Built-in caching and optimization
- Simple deployment - Deploy together with frontend
By using API routes effectively, you can build complete full-stack applications without needing a separate backend server.