Route Handlers and APIs in Next.js
- nextjs
- typescript
- backend
- webdev
- softwarearchitecture
- api
- nodejs
In Caching and Revalidation in Next.js, we looked at how the framework decides what to cache and when to throw that cache away. Route Handlers sit right next to that decision, because every one of them is a place where you can either lean on the framework's caching and rendering model or deliberately step outside it. Get that choice wrong and you end up with an endpoint that behaves like a static page when your users expect it to behave like an API.
This is part of the React & Next.js Complete Guide series. I'm assuming you already know what a Route Handler is at a syntax level, an exported GET or POST function in a route.ts file. What I want to cover here is when a Route Handler is the right tool at all, how to structure one for a real backend, and where the App Router's conventions quietly change behavior you'd expect to be obvious.
The bigger question underneath all of this, one I'd push you to answer before writing a single handler, is whether the API layer belongs in Next.js in the first place. For a lot of SaaS products it doesn't, and the honest answer is "use Route Handlers for the thin, Next.js-specific stuff and keep your real backend in NestJS." I'll get into where that line sits.
What a Route Handler actually replaces, and what it doesn't
A Route Handler is a file, app/api/orders/route.ts, that exports functions named after HTTP methods: GET, POST, PUT, PATCH, DELETE. Next.js maps requests to the matching export based on the URL path and method.
// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getOrdersForUser, createOrder } from "@/lib/orders";
import { getSessionUser } from "@/lib/auth";
const createOrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
});
export async function GET(request: NextRequest) {
const user = await getSessionUser(request);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const orders = await getOrdersForUser(user.id);
return NextResponse.json(orders);
}
export async function POST(request: NextRequest) {
const user = await getSessionUser(request);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const parsed = createOrderSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
const order = await createOrder(user.id, parsed.data);
return NextResponse.json(order, { status: 201 });
}
This replaces the Pages Router's pages/api/*.ts files, where you handled every method inside one default export and branched on req.method yourself. The App Router's per-method exports read cleaner, and each one gets its own request and response typing through NextRequest and NextResponse.
What it doesn't replace is a real backend service. A Route Handler runs as a serverless function or an edge function depending on your deployment target and the runtime you declare, and it shares the same deployment lifecycle as the rest of your Next.js app. If your product already has a NestJS service handling business logic, tenant isolation, and background jobs, duplicating that logic inside Route Handlers just to avoid a second deployment target usually costs you more than it saves. A reasonable default: use Route Handlers for things genuinely tied to the Next.js app itself. Webhook receivers for framework-specific integrations. Thin proxies that attach server-only secrets before forwarding to your real API. Form submissions that don't need to touch a database directly. Let the actual backend own everything else.
Runtime, caching, and the defaults that surprise people
Every Route Handler runs in one of two runtimes: Node.js (the default) or Edge, set with a runtime export.
// app/api/health/route.ts
export const runtime = "edge";
export async function GET() {
return new Response("ok", { status: 200 });
}
The Edge runtime starts faster and runs closer to the user geographically, but it's a stripped-down JavaScript environment. No native Node APIs. No arbitrary npm packages that depend on fs or Node-specific bindings. Connection pooling for a traditional PostgreSQL client generally doesn't work the way you'd expect, either. If your handler needs a full Node dependency, a PDF generation library, an image processing package, a database driver that assumes a persistent process, stay on the Node runtime. Reach for Edge when the handler is simple and stateless and latency actually matters: reading a cookie, checking a feature flag, redirecting based on geolocation.
The caching default is the part that trips up people coming from the Pages Router, where API routes were always dynamic. A GET Route Handler with no request-dependent logic, no cookies read, no headers inspected, no dynamic function called, can be statically cached and served without hitting your handler again.
// app/api/pricing/route.ts
// This has no per-request logic, so Next.js may cache it statically
export async function GET() {
const plans = await getPricingPlans();
return NextResponse.json(plans);
}
If getPricingPlans reads from a database that changes rarely, that's a fine outcome. If you expected this endpoint to reflect a price change the moment it happens in your admin panel, you need to either read something request-specific to opt the handler into dynamic rendering, or set the segment config explicitly:
export const dynamic = "force-dynamic";
export async function GET() {
const plans = await getPricingPlans();
return NextResponse.json(plans);
}
Any handler that reads cookies(), headers(), or the request's URL search params is automatically treated as dynamic, since those values differ per request by definition. The pitfall is the handlers that look dynamic to a human but aren't dynamic to Next.js, an endpoint whose only per-request behavior lives inside a function it calls, three layers down, that itself reads a cookie. Next.js can't see through that indirection reliably in every case, so for anything you need to guarantee is live, set dynamic = "force-dynamic" explicitly rather than relying on the framework to infer it correctly.
Request parsing, response shape, and error handling worth standardizing
A Route Handler receives a standard Request object (Next.js extends it as NextRequest with a few conveniences like .cookies and .nextUrl). Parsing the body, query params, and headers is manual, which is a good thing: it forces you to validate rather than trust.
// app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { searchProducts } from "@/lib/products";
const searchQuerySchema = z.object({
q: z.string().min(1).max(200),
page: z.coerce.number().int().min(1).default(1),
});
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = searchQuerySchema.safeParse(params);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid query parameters" },
{ status: 400 }
);
}
const results = await searchProducts(parsed.data.q, parsed.data.page);
return NextResponse.json(results);
}
Once you have more than a handful of Route Handlers, the lack of a shared framework for error handling starts to show. NestJS gives you exception filters that turn a thrown error into a consistent JSON shape automatically. Route Handlers give you nothing by default. Every handler has to catch its own errors and format its own response, or an unhandled exception surfaces as an opaque 500 with a stack trace you don't control the shape of. A small wrapper closes that gap:
// lib/api/with-error-handling.ts
import { NextRequest, NextResponse } from "next/server";
type Handler = (request: NextRequest) => Promise<NextResponse>;
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export function withErrorHandling(handler: Handler): Handler {
return async (request) => {
try {
return await handler(request);
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error("Unhandled Route Handler error", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};
}
// app/api/orders/[id]/route.ts
import { withErrorHandling, ApiError } from "@/lib/api/with-error-handling";
import { getOrderById } from "@/lib/orders";
export const GET = withErrorHandling(async (request) => {
const id = request.nextUrl.pathname.split("/").pop();
const order = await getOrderById(id!);
if (!order) {
throw new ApiError(404, "Order not found");
}
return NextResponse.json(order);
});
This is a small pattern, but it buys consistency across every handler in the app without needing a framework. The tradeoff is that it's still your own convention, not something enforced by the platform. On a team of more than a couple of engineers, document it somewhere visible, because nothing stops a new handler from skipping the wrapper and going back to an unstructured try/catch.
Authentication and authorization inside a Route Handler
Route Handlers don't come with built-in middleware for auth the way a NestJS guard does. Every handler that needs authentication has to check for it explicitly, or you push that check into Next.js middleware for routes matching a pattern.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySessionToken } from "@/lib/auth";
export async function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
const session = token ? await verifySessionToken(token) : null;
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.next();
}
export const config = {
matcher: ["/api/orders/:path*", "/api/billing/:path*"],
};
Middleware runs on the Edge runtime by default and executes before the Route Handler, which makes it a reasonable place for cheap, universal checks: is there a session token at all, is it well-formed. It's a poor place for anything that needs a database round trip for every request on every route it matches, since middleware runs on paths matching its config regardless of whether the underlying handler actually needs that check. Keep middleware checks fast and stateless, and do tenant-specific or role-specific authorization inside the handler itself, where you have the full request context and can return a more specific error than a blanket 401.
The production pitfall worth naming directly: authorization checked only in middleware and never re-verified inside the handler is fragile. If someone adds a new Route Handler under a path the middleware matcher doesn't cover, or if the matcher pattern has a typo, that handler is unauthenticated with no compiler or test catching it. Treat middleware as a first line of defense, not the only one, and verify the session and the tenant scope again inside handlers that touch anything sensitive.
Key takeaways
- Route Handlers replace `pages/api/*.ts` with per-HTTP-method exports, but they're not a replacement for a real backend service; keep business logic in NestJS (or equivalent) and use handlers for framework-specific glue.
- GET handlers follow the same static caching defaults as pages. Set `export const dynamic = "force-dynamic"` explicitly for anything that must be live, rather than trusting Next.js to infer dynamism from indirect logic.
- Choose the Node runtime for anything needing native dependencies or persistent connections, and the Edge runtime only for simple, stateless, latency-sensitive checks.
- There's no built-in error-handling framework for Route Handlers; a small wrapper that catches and formats errors consistently pays off once you have more than a few endpoints.
- Next.js middleware is a reasonable first layer for authentication, but it shouldn't be the only check. Verify session and tenant scope again inside handlers that touch sensitive data.
- Validate every request body and query param explicitly with something like Zod; Route Handlers give you a raw `Request` object and no automatic parsing or validation.
Frequently asked questions
Should I build my entire API inside Next.js Route Handlers instead of a separate backend?
For a small app with limited endpoints, it can work fine and saves you a second deployment target. Once you need background jobs, complex authorization, or a service that other clients besides your Next.js frontend will call, a dedicated backend like NestJS gives you better structure, testing, and separation of concerns than Route Handlers were designed for.
Why is my Route Handler returning a cached response even though the underlying data changed?
The handler likely has no request-dependent logic that Next.js can detect, so it defaults to static caching. Add `export const dynamic = "force-dynamic"` to the route file, or read something request-specific like a cookie or header, which opts the handler into dynamic rendering automatically.
Can a Route Handler stream a response instead of returning it all at once?
Yes. Return a `ReadableStream` wrapped in a `Response` object, which is useful for proxying a long-running request or streaming output from an LLM call without buffering the entire response in memory first.
Do I need CORS configuration for Route Handlers called from a different origin?
Yes, if the handler is called from a browser on a different domain than the one serving it. Set the appropriate `Access-Control-Allow-Origin` and related headers on the response, and handle the `OPTIONS` preflight method explicitly if the request uses a non-simple method or custom headers.
What's the difference between Route Handlers and Server Actions for mutations?
Route Handlers are standard HTTP endpoints, callable from anywhere, including third-party clients and webhooks. Server Actions are React Server Component functions invoked directly from a form or event handler with no manually constructed fetch call. Use Route Handlers when you need a stable, externally callable API surface; use Server Actions for mutations tied specifically to your app's own UI.
Is it safe to put secrets like API keys directly in a Route Handler?
Yes, as long as the handler runs on the server, which all Route Handlers do by definition. Secrets referenced inside a Route Handler never reach the client bundle. Just make sure you're not accidentally echoing a secret back in the response body or logging it somewhere that ends up in client-visible output.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.