Claude Code Next.js API Routes Best (2026)

Claude Code NextJS API Routes Best Practices: A Developer Guide

Building API routes in NextJS requires understanding the framework’s unique approach to server-side endpoints. When combined with Claude Code’s autonomous task execution and skill ecosystem, you can create production-ready APIs faster while maintaining high code quality. This guide covers practical patterns that work well in real-world applications.

Understanding the API Routes Architecture

NextJS API routes live in the pages/api directory (or app/api for App Router). Each file becomes a serverless function deployed alongside your frontend. This tight coupling offers benefits, shared types, consistent authentication patterns, and simplified deployment, but requires deliberate structure to scale.

Claude Code excels at scaffolding these routes efficiently. When you describe your endpoint requirements, it generates TypeScript implementations with proper typing, error handling, and validation. This automation becomes particularly valuable when building multiple endpoints that need consistent patterns across your application.

// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
type User = {
 id: string
 name: string
 email: string
}
export default function handler(
 req: NextApiRequest,
 res: NextApiResponse<User | { error: string }>
) {
 const { id } = req.query
 if (typeof id !== 'string') {
 return res.status(400).json({ error: 'Invalid user ID' })
 }
 // Fetch user from database
 const user = { id, name: 'Example User', email: '[email protected]' }
 res.status(200).json(user)
}

Pages Router vs. App Router API Routes

NextJS 13+ introduced the App Router, which changes how API routes are structured. Understanding the differences helps you choose the right approach for new projects and migrate existing ones effectively.

Feature Pages Router (pages/api) App Router (app/api)
Handler signature (req, res) functions Route Handler (Request) => Response
Streaming support No Yes (via Web Streams API)
Middleware integration Limited Native via middleware.ts
TypeScript inference Manual typing Automatic with Route Handlers
Edge runtime Via export const config Via export const runtime = 'edge'
Caching Manual Built-in with fetch cache controls

For new projects, the App Router approach offers better long-term ergonomics. For existing Pages Router applications, the patterns in this guide apply directly without migration.

Here is the equivalent App Router structure for the user endpoint above:

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(
 request: NextRequest,
 { params }: { params: { id: string } }
) {
 const { id } = params
 if (!id || typeof id !== 'string') {
 return NextResponse.json({ error: 'Invalid user ID' }, { status: 400 })
 }
 // Fetch user from database
 const user = { id, name: 'Example User', email: '[email protected]' }
 return NextResponse.json(user)
}

Request Validation Patterns

One of the most critical aspects of API route development is validating incoming data. Never trust client-provided input directly. Implement validation at the entry point of every endpoint.

For form handling and validation, Claude Code can integrate with libraries like Zod to create schemas that validate request bodies, query parameters, and headers. This approach reduces runtime errors and provides clear feedback when validation fails.

// pages/api/submit-feedback.ts
import { z } from 'zod'
import type { NextApiRequest, NextApiResponse } from 'next'
const FeedbackSchema = z.object({
 name: z.string().min(2).max(100),
 email: z.string().email(),
 message: z.string().min(10).max(1000),
 rating: z.number().int().min(1).max(5).optional(),
})
export default async function handler(
 req: NextApiRequest,
 res: NextApiResponse
) {
 if (req.method !== 'POST') {
 return res.status(405).json({ error: 'Method not allowed' })
 }
 const result = FeedbackSchema.safeParse(req.body)
 if (!result.success) {
 return res.status(400).json({
 error: 'Validation failed',
 details: result.error.flatten()
 })
 }
 // Process validated feedback
 const { name, email, message, rating } = result.data
 // Save to database, send notification, etc.
 res.status(200).json({ success: true })
}

Query Parameter Validation

Body validation is common practice, but query parameter validation is often overlooked. Type coercion from URL strings introduces subtle bugs that are hard to reproduce in testing. Zod handles this cleanly with its .coerce transforms:

// pages/api/products.ts
import { z } from 'zod'
import type { NextApiRequest, NextApiResponse } from 'next'
const QuerySchema = z.object({
 page: z.coerce.number().int().min(1).default(1),
 limit: z.coerce.number().int().min(1).max(100).default(20),
 category: z.string().optional(),
 sort: z.enum(['asc', 'desc']).default('asc'),
})
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 if (req.method !== 'GET') {
 return res.status(405).json({ error: 'Method not allowed' })
 }
 const result = QuerySchema.safeParse(req.query)
 if (!result.success) {
 return res.status(400).json({
 error: 'Invalid query parameters',
 details: result.error.flatten(),
 })
 }
 const { page, limit, category, sort } = result.data
 // Safe to use without further type narrowing
}

Without coercion, req.query.page is always a string or string array regardless of what the client sent. Passing "2" to a database LIMIT clause or arithmetic operation produces unexpected behavior that is difficult to trace back to its origin.

Authentication and Authorization

Securing API routes requires layered protection. For authentication, integrate with solutions like NextAuth.js or implement JWT validation. For authorization, verify user permissions before executing sensitive operations.

Claude Code’s skill for pdf generation can help create reports from authenticated user data, while the tdd skill ensures your auth flows have proper test coverage. Consider these patterns for protected endpoints:

// Middleware pattern for protected routes
import { getServerSession } from 'next-auth'
import { authOptions } from './auth/[...nextauth]'
export async function requireAuth(req: NextApiRequest) {
 const session = await getServerSession(req, authOptions)
 if (!session?.user) {
 throw new Error('Unauthorized')
 }
 return session
}

Role-Based Authorization

Authentication confirms who a user is. Authorization confirms what they are allowed to do. Many NextJS applications implement authentication correctly but skip fine-grained authorization, leaving sensitive operations accessible to any authenticated user.

// lib/auth.ts
export type UserRole = 'admin' | 'editor' | 'viewer'
export function requireRole(
 session: Session,
 required: UserRole | UserRole[]
): void {
 const userRole = session.user?.role as UserRole
 const allowedRoles = Array.isArray(required) ? required : [required]
 if (!allowedRoles.includes(userRole)) {
 throw new AuthorizationError(
 `Role '${userRole}' is not authorized for this action`
 )
 }
}
// pages/api/admin/users.ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 try {
 const session = await requireAuth(req)
 requireRole(session, 'admin')
 // Admin-only logic here
 const users = await db.user.findMany()
 return res.status(200).json(users)
 } catch (error) {
 if (error instanceof AuthorizationError) {
 return res.status(403).json({ error: error.message })
 }
 if (error instanceof AuthenticationError) {
 return res.status(401).json({ error: 'Unauthorized' })
 }
 return res.status(500).json({ error: 'Internal server error' })
 }
}

Rate Limiting

Authentication protects your data, but rate limiting protects your infrastructure. Without it, a single authenticated user can issue thousands of requests per minute against your serverless functions, generating unexpected cloud costs and degrading service for others.

// lib/rate-limit.ts
import { LRUCache } from 'lru-cache'
type Options = {
 uniqueTokenPerInterval?: number
 interval?: number
 limit?: number
}
export function rateLimit(options?: Options) {
 const tokenCache = new LRUCache({
 max: options?.uniqueTokenPerInterval ?? 500,
 ttl: options?.interval ?? 60000,
 })
 return {
 check: (token: string, limit: number) =>
 new Promise<void>((resolve, reject) => {
 const tokenCount = (tokenCache.get(token) as number[]) || [0]
 if (tokenCount[0] === 0) {
 tokenCache.set(token, tokenCount)
 }
 tokenCount[0] += 1
 const currentUsage = tokenCount[0]
 const isRateLimited = currentUsage >= limit
 if (isRateLimited) {
 reject(new Error('Rate limit exceeded'))
 } else {
 resolve()
 }
 }),
 }
}
const limiter = rateLimit({ interval: 60000, uniqueTokenPerInterval: 500 })
// In your handler:
await limiter.check(req.headers['x-forwarded-for'] as string ?? 'anonymous', 30)

Error Handling and Logging

Consistent error handling makes debugging significantly easier. Create utility functions that standardize error responses across all your endpoints. Track errors using your preferred observability solution, options include Sentry, Datadog, or cloud-native solutions.

For complex error scenarios, Claude Code can help generate comprehensive error handling logic that covers network failures, database timeouts, and edge cases specific to your business domain. The supermemory skill can assist in maintaining documentation of error patterns and their resolutions.

// lib/api-utils.ts
export function withErrorHandling(handler: Function) {
 return async (req: NextApiRequest, res: NextApiResponse) => {
 try {
 await handler(req, res)
 } catch (error) {
 console.error('API Error:', error)
 if (error instanceof ValidationError) {
 return res.status(400).json({ error: error.message })
 }
 if (error instanceof AuthenticationError) {
 return res.status(401).json({ error: 'Unauthorized' })
 }
 if (error instanceof AuthorizationError) {
 return res.status(403).json({ error: 'Forbidden' })
 }
 if (error instanceof NotFoundError) {
 return res.status(404).json({ error: error.message })
 }
 res.status(500).json({ error: 'Internal server error' })
 }
 }
}

Structured Error Classes

Generic Error objects make it difficult to distinguish between different failure categories in your catch blocks. Define a small hierarchy of application-specific errors so that your error handler can respond with the correct HTTP status without inspecting error messages:

// lib/errors.ts
export class AppError extends Error {
 constructor(
 message: string,
 public statusCode: number = 500,
 public code?: string
 ) {
 super(message)
 this.name = this.constructor.name
 }
}
export class ValidationError extends AppError {
 constructor(message: string) {
 super(message, 400, 'VALIDATION_ERROR')
 }
}
export class AuthenticationError extends AppError {
 constructor(message = 'Authentication required') {
 super(message, 401, 'UNAUTHENTICATED')
 }
}
export class AuthorizationError extends AppError {
 constructor(message = 'Insufficient permissions') {
 super(message, 403, 'FORBIDDEN')
 }
}
export class NotFoundError extends AppError {
 constructor(resource: string) {
 super(`${resource} not found`, 404, 'NOT_FOUND')
 }
}

With this hierarchy, your withErrorHandling wrapper becomes straightforward to maintain and extend as new failure modes are identified.

Performance Optimization

API route performance impacts overall application responsiveness. Several strategies help minimize latency:

Avoid unnecessary database queries by implementing proper indexing and caching strategies. Use unstable_cache in NextJS for frequently accessed data.

Batch operations when clients need multiple related resources. Instead of multiple API calls, design endpoints that return compound responses.

Implement pagination for list endpoints from the start. Cursor-based pagination performs better than offset-based approaches for large datasets.

When building frontend integrations with these APIs, use the frontend-design skill to create components that handle loading states, error displays, and data refresh patterns elegantly.

Response Caching with Cache-Control Headers

For read-heavy endpoints returning data that changes infrequently, HTTP cache headers are the most effective performance optimization available. They shift work off your serverless functions entirely by letting CDN layers and browsers serve cached responses.

// pages/api/config.ts - Suitable for data that changes at most hourly
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 if (req.method !== 'GET') {
 return res.status(405).json({ error: 'Method not allowed' })
 }
 const config = await getPublicConfig()
 // Cache at CDN for 5 minutes, allow stale for 1 minute while revalidating
 res.setHeader('Cache-Control', 's-maxage=300, stale-while-revalidate=60')
 return res.status(200).json(config)
}

Never set cache headers on endpoints that return user-specific or sensitive data. A shared CDN cache that serves one user’s data to another user is a serious security incident.

Database Connection Pooling

Serverless functions are short-lived. Each cold start creates a new process that needs its own database connection. Without a connection pooler, high traffic spikes can exhaust your database’s connection limit in seconds.

// lib/db.ts
import { PrismaClient } from '@prisma/client'
// Prevent multiple instances in development with hot reloading
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const db =
 globalForPrisma.prisma ||
 new PrismaClient({
 log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
 })
if (process.env.NODE_ENV !== 'production') {
 globalForPrisma.prisma = db
}

For production at scale, use an external connection pooler like PgBouncer or Prisma Accelerate that sits between your serverless functions and the database, multiplexing hundreds of function connections into a smaller pool of persistent database connections.

HTTP Method Handling

Each HTTP verb should have clear semantics in your API design:

  • GET - Retrieve data without side effects
  • POST - Create new resources
  • PUT - Replace existing resources entirely
  • PATCH - Partial updates
  • DELETE - Remove resources

Structure your route files to handle method-specific logic cleanly, returning appropriate status codes for each case.

export default function handler(req: NextApiRequest, res: NextApiResponse) {
 switch (req.method) {
 case 'GET':
 return handleGet(req, res)
 case 'POST':
 return handlePost(req, res)
 case 'PUT':
 return handlePut(req, res)
 case 'DELETE':
 return handleDelete(req, res)
 default:
 res.setHeader('Allow', ['GET', 'POST', 'PUT', 'DELETE'])
 res.status(405).json({ error: 'Method not allowed' })
 }
}

Including an Allow header in your 405 response is a small but useful touch, it tells API clients which methods the endpoint actually supports, which reduces back-and-forth debugging when integrating against your API.

CORS Configuration

When your NextJS API is consumed by clients on different origins, mobile apps, third-party integrations, or a separately deployed frontend, you need explicit CORS configuration. NextJS does not enable CORS by default.

// lib/cors.ts
import type { NextApiRequest, NextApiResponse } from 'next'
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(',') ?? []
export function setCorsHeaders(req: NextApiRequest, res: NextApiResponse): boolean {
 const origin = req.headers.origin
 if (origin && ALLOWED_ORIGINS.includes(origin)) {
 res.setHeader('Access-Control-Allow-Origin', origin)
 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
 res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
 res.setHeader('Access-Control-Max-Age', '86400')
 }
 // Handle preflight
 if (req.method === 'OPTIONS') {
 res.status(204).end()
 return true // Signal that response is complete
 }
 return false
}
// Usage in a handler
export default function handler(req: NextApiRequest, res: NextApiResponse) {
 if (setCorsHeaders(req, res)) return // Preflight handled
 // Rest of handler logic
}

Avoid setting Access-Control-Allow-Origin: * on endpoints that require authentication. Wildcard CORS combined with credential headers is blocked by browsers for good reason, and it signals that the CORS configuration is not well thought out.

Testing Your API Routes

The tdd skill proves invaluable when building API routes. Write integration tests that exercise your endpoints with various inputs, including edge cases and error conditions. Test tools like Jest and Supertest work well for this purpose.

Mock external dependencies during testing to ensure tests run quickly and reliably. Database connections, third-party APIs, and authentication providers should have test doubles that simulate different scenarios.

// __tests__/api/users.test.ts
import { createMocks } from 'node-mocks-http'
import handler from '../../pages/api/users/[id]'
describe('GET /api/users/[id]', () => {
 it('returns 400 when id is missing', async () => {
 const { req, res } = createMocks({
 method: 'GET',
 query: {},
 })
 await handler(req, res)
 expect(res._getStatusCode()).toBe(400)
 expect(JSON.parse(res._getData())).toEqual({ error: 'Invalid user ID' })
 })
 it('returns 200 with valid id', async () => {
 const { req, res } = createMocks({
 method: 'GET',
 query: { id: 'user_123' },
 })
 await handler(req, res)
 expect(res._getStatusCode()).toBe(200)
 expect(JSON.parse(res._getData())).toHaveProperty('id', 'user_123')
 })
 it('returns 405 for POST requests', async () => {
 const { req, res } = createMocks({ method: 'POST' })
 await handler(req, res)
 expect(res._getStatusCode()).toBe(405)
 })
})

The node-mocks-http library provides lightweight request and response mocks that avoid spinning up a full HTTP server for every test. Combine this with Jest’s module mocking to isolate database and authentication dependencies, and your entire API test suite runs in seconds.

Conclusion

Building solid NextJS API routes requires attention to validation, authentication, error handling, and performance from the start. Claude Code accelerates development of these patterns through intelligent code generation and integration with specialized skills. By applying these best practices, your API endpoints will be secure, maintainable, and performant.

The patterns in this guide are designed to compose well together. Start with strict request validation using Zod, layer in structured error classes and a withErrorHandling wrapper, add role-based authorization for protected endpoints, and apply caching headers where the data permits. Each layer is independently valuable, and together they produce API routes that are resilient enough for production from day one.

For additional NextJS optimization strategies, explore the guide on NextJS deployment optimization to ensure your API routes perform optimally in production environments.



This site was built by 5 autonomous agents running in tmux while I was in Bali. 2,500 articles. Zero manual work. 100% quality gate pass rate. The orchestration configs, sprint templates, and quality gates that made that possible are in the Zovo Lifetime bundle. Along with 16 CLAUDE.md templates and 80 tested prompts. **[See how the pipeline works →](https://zovo.one/lifetime?utm_source=ccg&utm_medium=cta-skills&utm_campaign=claude-code-nextjs-api-routes-best-practices)** $99 once. I'm a solo dev in Da Nang. This is how I scale.

Related Reading

Built by theluckystrike. More at zovo.one

Find the right skill → Browse 155+ skills in our Skill Finder.

See Also

Try it: Paste your error into our Error Diagnostic for an instant fix.