Back to Blog
Next.jsAPIBackendTypeScript

Next.js API Routes Best Practices

Build robust APIs with Next.js. From route handlers to middleware to authentication patterns.

B
Bootspring Team
Engineering
September 13, 2021
8 min read

Next.js API routes enable full-stack applications. Here's how to build them effectively.

Basic Route Handler#

1// app/api/users/route.ts (App Router) 2import { NextRequest, NextResponse } from 'next/server'; 3 4export async function GET(request: NextRequest) { 5 const users = await db.user.findMany(); 6 7 return NextResponse.json(users); 8} 9 10export async function POST(request: NextRequest) { 11 const body = await request.json(); 12 13 const user = await db.user.create({ 14 data: body, 15 }); 16 17 return NextResponse.json(user, { status: 201 }); 18} 19 20// pages/api/users.ts (Pages Router) 21import type { NextApiRequest, NextApiResponse } from 'next'; 22 23export default async function handler( 24 req: NextApiRequest, 25 res: NextApiResponse 26) { 27 if (req.method === 'GET') { 28 const users = await db.user.findMany(); 29 return res.json(users); 30 } 31 32 if (req.method === 'POST') { 33 const user = await db.user.create({ data: req.body }); 34 return res.status(201).json(user); 35 } 36 37 res.setHeader('Allow', ['GET', 'POST']); 38 res.status(405).end(`Method ${req.method} Not Allowed`); 39}

Dynamic Routes#

1// app/api/users/[id]/route.ts 2import { NextRequest, NextResponse } from 'next/server'; 3 4interface RouteParams { 5 params: { id: string }; 6} 7 8export async function GET( 9 request: NextRequest, 10 { params }: RouteParams 11) { 12 const user = await db.user.findUnique({ 13 where: { id: params.id }, 14 }); 15 16 if (!user) { 17 return NextResponse.json( 18 { error: 'User not found' }, 19 { status: 404 } 20 ); 21 } 22 23 return NextResponse.json(user); 24} 25 26export async function PUT( 27 request: NextRequest, 28 { params }: RouteParams 29) { 30 const body = await request.json(); 31 32 const user = await db.user.update({ 33 where: { id: params.id }, 34 data: body, 35 }); 36 37 return NextResponse.json(user); 38} 39 40export async function DELETE( 41 request: NextRequest, 42 { params }: RouteParams 43) { 44 await db.user.delete({ 45 where: { id: params.id }, 46 }); 47 48 return new NextResponse(null, { status: 204 }); 49}

Request Handling#

1// Query parameters 2export async function GET(request: NextRequest) { 3 const { searchParams } = new URL(request.url); 4 const page = parseInt(searchParams.get('page') || '1'); 5 const limit = parseInt(searchParams.get('limit') || '10'); 6 const search = searchParams.get('search') || ''; 7 8 const users = await db.user.findMany({ 9 where: { 10 name: { contains: search, mode: 'insensitive' }, 11 }, 12 skip: (page - 1) * limit, 13 take: limit, 14 }); 15 16 const total = await db.user.count({ 17 where: { 18 name: { contains: search, mode: 'insensitive' }, 19 }, 20 }); 21 22 return NextResponse.json({ 23 data: users, 24 pagination: { 25 page, 26 limit, 27 total, 28 pages: Math.ceil(total / limit), 29 }, 30 }); 31} 32 33// Headers and cookies 34export async function GET(request: NextRequest) { 35 const authHeader = request.headers.get('authorization'); 36 const userAgent = request.headers.get('user-agent'); 37 38 const token = request.cookies.get('token')?.value; 39 40 // Set response headers 41 return NextResponse.json( 42 { data: 'example' }, 43 { 44 headers: { 45 'X-Custom-Header': 'value', 46 'Cache-Control': 'max-age=3600', 47 }, 48 } 49 ); 50} 51 52// Set cookies in response 53export async function POST(request: NextRequest) { 54 const response = NextResponse.json({ success: true }); 55 56 response.cookies.set('token', 'jwt-token-here', { 57 httpOnly: true, 58 secure: process.env.NODE_ENV === 'production', 59 sameSite: 'strict', 60 maxAge: 60 * 60 * 24 * 7, // 1 week 61 }); 62 63 return response; 64}

Validation#

1import { z } from 'zod'; 2import { NextRequest, NextResponse } from 'next/server'; 3 4const createUserSchema = z.object({ 5 email: z.string().email(), 6 name: z.string().min(2).max(100), 7 password: z.string().min(8), 8}); 9 10export async function POST(request: NextRequest) { 11 const body = await request.json(); 12 13 const result = createUserSchema.safeParse(body); 14 15 if (!result.success) { 16 return NextResponse.json( 17 { 18 error: 'Validation failed', 19 details: result.error.flatten().fieldErrors, 20 }, 21 { status: 400 } 22 ); 23 } 24 25 const { email, name, password } = result.data; 26 27 // Check for existing user 28 const existing = await db.user.findUnique({ where: { email } }); 29 30 if (existing) { 31 return NextResponse.json( 32 { error: 'Email already registered' }, 33 { status: 409 } 34 ); 35 } 36 37 const hashedPassword = await bcrypt.hash(password, 10); 38 39 const user = await db.user.create({ 40 data: { 41 email, 42 name, 43 password: hashedPassword, 44 }, 45 }); 46 47 return NextResponse.json( 48 { id: user.id, email: user.email, name: user.name }, 49 { status: 201 } 50 ); 51} 52 53// Reusable validation helper 54function validateBody<T>(schema: z.Schema<T>) { 55 return async (request: NextRequest) => { 56 try { 57 const body = await request.json(); 58 return schema.parse(body); 59 } catch (error) { 60 if (error instanceof z.ZodError) { 61 throw new ValidationError(error.flatten().fieldErrors); 62 } 63 throw error; 64 } 65 }; 66}

Error Handling#

1// lib/api-error.ts 2export class ApiError extends Error { 3 constructor( 4 public statusCode: number, 5 message: string, 6 public code?: string 7 ) { 8 super(message); 9 } 10} 11 12export function handleApiError(error: unknown) { 13 console.error('API Error:', error); 14 15 if (error instanceof ApiError) { 16 return NextResponse.json( 17 { error: error.message, code: error.code }, 18 { status: error.statusCode } 19 ); 20 } 21 22 if (error instanceof z.ZodError) { 23 return NextResponse.json( 24 { error: 'Validation failed', details: error.flatten() }, 25 { status: 400 } 26 ); 27 } 28 29 // Prisma errors 30 if (error instanceof Prisma.PrismaClientKnownRequestError) { 31 if (error.code === 'P2002') { 32 return NextResponse.json( 33 { error: 'Resource already exists' }, 34 { status: 409 } 35 ); 36 } 37 if (error.code === 'P2025') { 38 return NextResponse.json( 39 { error: 'Resource not found' }, 40 { status: 404 } 41 ); 42 } 43 } 44 45 return NextResponse.json( 46 { error: 'Internal server error' }, 47 { status: 500 } 48 ); 49} 50 51// Usage in route 52export async function POST(request: NextRequest) { 53 try { 54 const body = await request.json(); 55 const user = await createUser(body); 56 return NextResponse.json(user, { status: 201 }); 57 } catch (error) { 58 return handleApiError(error); 59 } 60}

Authentication Middleware#

1// lib/auth.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import jwt from 'jsonwebtoken'; 4 5interface AuthUser { 6 id: string; 7 email: string; 8 role: string; 9} 10 11export function withAuth( 12 handler: (request: NextRequest, user: AuthUser) => Promise<NextResponse> 13) { 14 return async (request: NextRequest) => { 15 const token = request.headers.get('authorization')?.replace('Bearer ', ''); 16 17 if (!token) { 18 return NextResponse.json( 19 { error: 'Authentication required' }, 20 { status: 401 } 21 ); 22 } 23 24 try { 25 const decoded = jwt.verify(token, process.env.JWT_SECRET!) as AuthUser; 26 return handler(request, decoded); 27 } catch { 28 return NextResponse.json( 29 { error: 'Invalid token' }, 30 { status: 401 } 31 ); 32 } 33 }; 34} 35 36// Role-based auth 37export function withRole(roles: string[]) { 38 return ( 39 handler: (request: NextRequest, user: AuthUser) => Promise<NextResponse> 40 ) => { 41 return withAuth(async (request, user) => { 42 if (!roles.includes(user.role)) { 43 return NextResponse.json( 44 { error: 'Insufficient permissions' }, 45 { status: 403 } 46 ); 47 } 48 return handler(request, user); 49 }); 50 }; 51} 52 53// Usage 54// app/api/admin/users/route.ts 55export const GET = withRole(['admin'])(async (request, user) => { 56 const users = await db.user.findMany(); 57 return NextResponse.json(users); 58});

Rate Limiting#

1// lib/rate-limit.ts 2import { NextRequest, NextResponse } from 'next/server'; 3 4const rateLimit = new Map<string, { count: number; resetTime: number }>(); 5 6export function withRateLimit( 7 limit: number, 8 windowMs: number 9) { 10 return ( 11 handler: (request: NextRequest) => Promise<NextResponse> 12 ) => { 13 return async (request: NextRequest) => { 14 const ip = request.ip || 'anonymous'; 15 const now = Date.now(); 16 17 const record = rateLimit.get(ip); 18 19 if (!record || now > record.resetTime) { 20 rateLimit.set(ip, { count: 1, resetTime: now + windowMs }); 21 } else if (record.count >= limit) { 22 return NextResponse.json( 23 { error: 'Too many requests' }, 24 { 25 status: 429, 26 headers: { 27 'Retry-After': String(Math.ceil((record.resetTime - now) / 1000)), 28 }, 29 } 30 ); 31 } else { 32 record.count++; 33 } 34 35 return handler(request); 36 }; 37 }; 38} 39 40// Usage 41export const POST = withRateLimit(10, 60000)(async (request) => { 42 // Handle request 43 return NextResponse.json({ success: true }); 44});

File Uploads#

1// app/api/upload/route.ts 2import { writeFile } from 'fs/promises'; 3import { NextRequest, NextResponse } from 'next/server'; 4import path from 'path'; 5 6export async function POST(request: NextRequest) { 7 const formData = await request.formData(); 8 const file = formData.get('file') as File; 9 10 if (!file) { 11 return NextResponse.json( 12 { error: 'No file uploaded' }, 13 { status: 400 } 14 ); 15 } 16 17 // Validate file type 18 const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; 19 if (!allowedTypes.includes(file.type)) { 20 return NextResponse.json( 21 { error: 'Invalid file type' }, 22 { status: 400 } 23 ); 24 } 25 26 // Validate file size (5MB) 27 if (file.size > 5 * 1024 * 1024) { 28 return NextResponse.json( 29 { error: 'File too large' }, 30 { status: 400 } 31 ); 32 } 33 34 const bytes = await file.arrayBuffer(); 35 const buffer = Buffer.from(bytes); 36 37 const filename = `${Date.now()}-${file.name}`; 38 const filepath = path.join(process.cwd(), 'public/uploads', filename); 39 40 await writeFile(filepath, buffer); 41 42 return NextResponse.json({ 43 url: `/uploads/${filename}`, 44 }); 45} 46 47export const config = { 48 api: { 49 bodyParser: false, 50 }, 51};

Streaming Responses#

1// app/api/stream/route.ts 2export async function GET() { 3 const encoder = new TextEncoder(); 4 5 const stream = new ReadableStream({ 6 async start(controller) { 7 for (let i = 0; i < 10; i++) { 8 const data = `data: ${JSON.stringify({ count: i })}\n\n`; 9 controller.enqueue(encoder.encode(data)); 10 await new Promise(r => setTimeout(r, 1000)); 11 } 12 controller.close(); 13 }, 14 }); 15 16 return new Response(stream, { 17 headers: { 18 'Content-Type': 'text/event-stream', 19 'Cache-Control': 'no-cache', 20 'Connection': 'keep-alive', 21 }, 22 }); 23}

Best Practices#

Structure: ✓ Group related routes in folders ✓ Use consistent naming conventions ✓ Extract shared logic to lib/ ✓ Keep handlers focused Security: ✓ Validate all inputs ✓ Sanitize outputs ✓ Use proper authentication ✓ Implement rate limiting Performance: ✓ Use edge runtime when possible ✓ Cache responses appropriately ✓ Minimize database queries ✓ Handle errors gracefully Testing: ✓ Test happy paths ✓ Test error cases ✓ Test validation ✓ Mock external services

Conclusion#

Next.js API routes enable full-stack development with minimal configuration. Use proper validation, error handling, and authentication for production APIs. Structure routes logically and extract shared middleware for maintainability.

Share this article

Help spread the word about Bootspring