Next.js API Routes: Best Practices and Patterns
Build robust Next.js API routes with proper validation, error handling, authentication, rate limiting, and testing patterns for production apps.
What you'll learn
- ✓Building Route Handlers in the App Router
- ✓Input validation and type-safe responses
- ✓Authentication and authorization patterns
- ✓Error handling and structured API responses
Prerequisites
- •Basic Next.js App Router knowledge
- •Familiarity with REST API concepts
Route Handlers in the App Router
The App Router replaces the old pages/api approach with Route Handlers. You create a route.ts file inside the app directory, and each exported function name corresponds to an HTTP method:
// app/api/posts/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const posts = await db.post.findMany({
orderBy: { createdAt: "desc" },
take: 20,
});
return NextResponse.json(posts);
}
export async function POST(request: Request) {
const body = await request.json();
const post = await db.post.create({
data: {
title: body.title,
content: body.content,
},
});
return NextResponse.json(post, { status: 201 });
}
This is cleaner than the old pattern of switching on req.method. Each HTTP method gets its own function, and Next.js returns 405 Method Not Allowed automatically for methods you do not export.
Structured API Responses
Consistent response shapes make your API predictable for consumers. Define a standard envelope and use it everywhere:
// lib/api-response.ts
import { NextResponse } from "next/server";
type ApiSuccess<T> = {
success: true;
data: T;
meta?: Record<string, unknown>;
};
type ApiError = {
success: false;
error: {
code: string;
message: string;
details?: unknown;
};
};
type ApiResponse<T> = ApiSuccess<T> | ApiError;
export function apiSuccess<T>(
data: T,
meta?: Record<string, unknown>,
status = 200
) {
const body: ApiSuccess<T> = { success: true, data };
if (meta) body.meta = meta;
return NextResponse.json(body, { status });
}
export function apiError(
code: string,
message: string,
status: number,
details?: unknown
) {
const body: ApiError = {
success: false,
error: { code, message },
};
if (details) body.error.details = details;
return NextResponse.json(body, { status });
}
Now your route handlers return clean, consistent responses:
// app/api/posts/route.ts
import { apiSuccess, apiError } from "@/lib/api-response";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const [posts, total] = await Promise.all([
db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: "desc" },
}),
db.post.count(),
]);
return apiSuccess(posts, {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
});
}
Input Validation with Zod
Never trust client input. Use Zod to validate request bodies, query parameters, and dynamic route segments:
// app/api/posts/route.ts
import { z } from "zod";
import { apiSuccess, apiError } from "@/lib/api-response";
const CreatePostSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
content: z.string().min(10, "Content must be at least 10 characters"),
tags: z.array(z.string()).max(5).optional(),
published: z.boolean().default(false),
});
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return apiError("INVALID_JSON", "Request body must be valid JSON", 400);
}
const result = CreatePostSchema.safeParse(body);
if (!result.success) {
return apiError(
"VALIDATION_ERROR",
"Invalid request data",
422,
result.error.flatten().fieldErrors
);
}
const post = await db.post.create({ data: result.data });
return apiSuccess(post, undefined, 201);
}
The validation error response includes field-level details, which makes it easy for frontend code to show inline error messages.
Dynamic Route Segments
Handle individual resources with dynamic segments. The route parameter comes in through the function’s second argument:
// app/api/posts/[id]/route.ts
import { z } from "zod";
import { apiSuccess, apiError } from "@/lib/api-response";
const ParamsSchema = z.object({
id: z.string().cuid(),
});
const UpdatePostSchema = z.object({
title: z.string().min(1).max(200).optional(),
content: z.string().min(10).optional(),
published: z.boolean().optional(),
});
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const validation = ParamsSchema.safeParse({ id });
if (!validation.success) {
return apiError("INVALID_ID", "Invalid post ID format", 400);
}
const post = await db.post.findUnique({ where: { id } });
if (!post) {
return apiError("NOT_FOUND", "Post not found", 404);
}
return apiSuccess(post);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const result = UpdatePostSchema.safeParse(body);
if (!result.success) {
return apiError("VALIDATION_ERROR", "Invalid data", 422, result.error.flatten().fieldErrors);
}
try {
const updated = await db.post.update({
where: { id },
data: result.data,
});
return apiSuccess(updated);
} catch {
return apiError("NOT_FOUND", "Post not found", 404);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
await db.post.delete({ where: { id } });
return apiSuccess({ deleted: true });
} catch {
return apiError("NOT_FOUND", "Post not found", 404);
}
}
Authentication Middleware Pattern
Create a reusable authentication wrapper instead of repeating auth checks in every handler:
// lib/api-auth.ts
import { jwtVerify } from "jose";
import { apiError } from "./api-response";
type AuthenticatedUser = {
id: string;
email: string;
role: "user" | "admin";
};
type AuthenticatedHandler = (
request: Request,
context: { params: Promise<Record<string, string>>; user: AuthenticatedUser }
) => Promise<Response>;
export function withAuth(handler: AuthenticatedHandler) {
return async (
request: Request,
context: { params: Promise<Record<string, string>> }
) => {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return apiError("UNAUTHORIZED", "Missing or invalid authorization header", 401);
}
const token = authHeader.slice(7);
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret);
const user: AuthenticatedUser = {
id: payload.sub as string,
email: payload.email as string,
role: (payload.role as "user" | "admin") || "user",
};
return handler(request, { ...context, user });
} catch {
return apiError("UNAUTHORIZED", "Invalid or expired token", 401);
}
};
}
export function withAdmin(handler: AuthenticatedHandler) {
return withAuth(async (request, context) => {
if (context.user.role !== "admin") {
return apiError("FORBIDDEN", "Admin access required", 403);
}
return handler(request, context);
});
}
Use the wrapper in your route handlers:
// app/api/admin/users/route.ts
import { withAdmin } from "@/lib/api-auth";
import { apiSuccess } from "@/lib/api-response";
export const GET = withAdmin(async (request, { user }) => {
const users = await db.user.findMany({
select: { id: true, email: true, role: true, createdAt: true },
});
return apiSuccess(users);
});
Error Handling
Wrap your handlers in a try-catch to prevent unhandled errors from crashing the response:
// lib/api-handler.ts
import { apiError } from "./api-response";
type RouteHandler = (
request: Request,
context: { params: Promise<Record<string, string>> }
) => Promise<Response>;
export function withErrorHandler(handler: RouteHandler): RouteHandler {
return async (request, context) => {
try {
return await handler(request, context);
} catch (error) {
console.error("Unhandled API error:", error);
if (error instanceof SyntaxError) {
return apiError("INVALID_JSON", "Malformed JSON in request body", 400);
}
return apiError(
"INTERNAL_ERROR",
"An unexpected error occurred",
500
);
}
};
}
Compose it with your auth wrapper:
// app/api/posts/route.ts
import { withAuth } from "@/lib/api-auth";
import { withErrorHandler } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
export const POST = withErrorHandler(
withAuth(async (request, { user }) => {
const body = await request.json();
const post = await db.post.create({
data: {
...body,
authorId: user.id,
},
});
return apiSuccess(post, undefined, 201);
})
);
CORS Configuration
If your API needs to be called from different origins, configure CORS headers:
// app/api/public/route.ts
import { NextResponse } from "next/server";
const ALLOWED_ORIGINS = [
"https://myapp.com",
"https://staging.myapp.com",
];
function getCorsHeaders(origin: string | null) {
const headers: Record<string, string> = {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
if (origin && ALLOWED_ORIGINS.includes(origin)) {
headers["Access-Control-Allow-Origin"] = origin;
}
return headers;
}
export async function OPTIONS(request: Request) {
const origin = request.headers.get("origin");
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(origin),
});
}
export async function GET(request: Request) {
const origin = request.headers.get("origin");
const data = await db.post.findMany({ where: { published: true } });
return NextResponse.json(data, {
headers: getCorsHeaders(origin),
});
}
The OPTIONS handler responds to preflight requests. The CORS headers are added to every response. Only whitelisted origins get access.
Streaming Responses
For long-running operations or real-time data, use streaming:
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
const data = JSON.stringify({ count: i, timestamp: Date.now() });
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
await new Promise((resolve) => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
This creates a Server-Sent Events stream that sends data every second. The client can consume it with the EventSource API or a simple fetch with a ReadableStream reader.
Caching API Responses
Control caching behavior with the standard Cache-Control header or Next.js-specific options:
// app/api/posts/route.ts
import { NextResponse } from "next/server";
// Static response: cache for 60 seconds, revalidate in background
export async function GET() {
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 20,
});
return NextResponse.json(posts, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
},
});
}
// Alternatively, use Next.js route segment config
export const revalidate = 60;
For routes that should never be cached (authenticated endpoints, writes), opt out explicitly:
export const dynamic = "force-dynamic";
File Upload Handling
Handle multipart form data for file uploads:
// app/api/upload/route.ts
import { apiSuccess, apiError } from "@/lib/api-response";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { randomUUID } from "crypto";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return apiError("NO_FILE", "No file provided", 400);
}
if (file.size > MAX_FILE_SIZE) {
return apiError("FILE_TOO_LARGE", "File must be under 10MB", 400);
}
if (!ALLOWED_TYPES.includes(file.type)) {
return apiError("INVALID_TYPE", `Allowed types: ${ALLOWED_TYPES.join(", ")}`, 400);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const ext = file.name.split(".").pop();
const filename = `${randomUUID()}.${ext}`;
const uploadDir = join(process.cwd(), "public", "uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, filename), buffer);
return apiSuccess(
{ url: `/uploads/${filename}`, filename, size: file.size },
undefined,
201
);
}
Route Handler vs Server Action
A common question is when to use a Route Handler versus a Server Action:
Use Route Handlers when:
- External clients (mobile apps, third-party services) need to call your API
- You need fine-grained control over HTTP methods, headers, and status codes
- You are building a webhook endpoint
- You need streaming responses
Use Server Actions when:
- Your own Next.js frontend is the only consumer
- You are handling form submissions
- You want automatic cache revalidation
- You want progressive enhancement (forms that work without JS)
They complement each other. Many applications use both: Server Actions for internal mutations and Route Handlers for external-facing APIs.
Wrapping Up
Next.js Route Handlers give you everything you need to build production-grade APIs alongside your frontend. The key patterns to adopt are: structured response envelopes for consistency, Zod validation for safety, composable auth wrappers for security, and proper error handling for reliability.
Start by creating the api-response.ts and api-auth.ts utility files. Once those are in place, every new route handler becomes a few lines of business logic wrapped in a consistent, secure shell. Your API consumers, whether they are your own frontend, a mobile app, or a third-party integration, will thank you for the predictable response format.
Related articles
- Next.js Rate Limiting API Routes in Next.js
Implement rate limiting for Next.js API routes and route handlers using in-memory stores, Redis, and middleware patterns to protect your endpoints.
- Next.js Next.js i18n: Complete Internationalization Guide
Build multilingual Next.js apps with the App Router using locale routing, server-side translations, dynamic language switching, and SEO best practices.
- Next.js Next.js Server Actions: Forms and Mutations Guide
Learn how to use Next.js Server Actions for form handling, data mutations, optimistic updates, and error handling with practical examples.
- Next.js Next.js Streaming and Loading UI: Instant Pages
Build instant-loading Next.js pages with streaming, Suspense boundaries, loading.tsx files, and skeleton UIs for the best user experience.