Type something to search...
Next.js Route Handlers

Next.js Route Handlers

Every framework eventually has to answer the question "what if the client just needs raw JSON, not HTML?" In the Pages Router, the answer was API Routes — files under pages/api that exported a single handler function and gave you a Node.js-style req/res pair. The App Router answers the same question differently, with a convention called Route Handlers, and the differences are big enough that treating them as "API Routes but renamed" will get you into trouble.

A Route Handler is a route.js (or route.ts) file that exports functions named after HTTP methods — GET, POST, PUT, and so on — and builds its request and response objects on top of the standard Web Request and Response APIs instead of Node's http module. That sounds like a small implementation detail, but it changes how you read bodies, how you set headers, how caching works, and even which runtime your code can execute in. This article walks through the convention in full, with enough surrounding context that you'll know not just how to write a Route Handler, but when you actually want one.

Route Handlers Are Not API Routes With a New Name

If you've worked with the Pages Router, it's tempting to assume app/api/users/route.ts is a drop-in replacement for pages/api/users.ts. It isn't, and conflating the two causes real bugs.

API Routes hand you a single function (req, res) where req is an augmented Node IncomingMessage and res is an augmented ServerResponse. You call res.status(200).json(...) and mutate the response object as you go. Route Handlers hand you nothing to mutate — you receive a Request, you return a Response, and that's the whole contract. There's no res.send(), no res.end(), no implicit "the response finishes when the function returns" ambiguity. You build a Response object (or use the NextResponse helper) and return it, exactly like you would in a Cloudflare Worker or a Deno Deploy function.

The other structural difference is that Route Handlers live inside app, so they participate in the same file-system routing as pages, layouts, and loading states — with one hard restriction: a route.js file cannot sit in the same route segment as a page.js file. app/dashboard/route.ts and app/dashboard/page.tsx cannot coexist. Each of those files claims the segment for a different purpose — page.js renders UI, route.js handles raw requests — and Next.js won't let one segment try to do both.

If you're maintaining a codebase that already has pages/api routes and is migrating to the App Router incrementally, know that both conventions can run side by side. Next.js explicitly does not require you to migrate every API Route to a Route Handler before you can start using the App Router elsewhere in the same project. You just can't mix the two conventions for the same URL.

The route.js Convention

A Route Handler is defined by exporting an async function named after the HTTP method it handles:

export async function GET(request: Request) {}
export async function GET(request) {}

That's the entire convention. There's no default export, no wrapper function, no export default handler pattern to remember. You export as many method handlers as the route needs, in the same file:

export async function GET(request: Request) {
  const todos = await db.todo.findMany();
  return Response.json(todos);
}

export async function POST(request: Request) {
  const body = await request.json();
  const todo = await db.todo.create({ data: body });
  return Response.json(todo, { status: 201 });
}

Route Handlers nest anywhere inside app the same way page.js and layout.js do, so app/api/todos/[id]/route.ts is a perfectly normal way to express /api/todos/123.

Supported HTTP Methods

Next.js recognizes GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. If a request comes in with a method you haven't exported a handler for, Next.js automatically responds with 405 Method Not Allowed — you don't need to write that branch yourself. That also means you get a free, spec-compliant OPTIONS handler: if you don't define one, Next.js implements it for you and sets the Allow header based on whichever methods you did export. This matters more than it looks like it should, because browsers send OPTIONS preflight requests automatically before certain cross-origin calls, and a route that silently 404s on OPTIONS will break CORS for reasons that have nothing to do with your CORS headers (more on this below).

Request: Web API by Default, NextRequest When You Need More

The parameter your handler receives is a real Request object — the same interface you'd get in a Service Worker or a fetch polyfill. That means request.headers, request.method, request.url, and request.json() all behave exactly as the Web platform defines them, with no framework-specific quirks to memorize.

Next.js layers NextRequest on top of that base Request to add a handful of conveniences you'll use constantly: a parsed nextUrl object, a cookies map, and geolocation-adjacent helpers depending on your deployment target. Type the parameter as NextRequest whenever you plan to use any of that:

import { type NextRequest } from "next/server";

export function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const query = searchParams.get("query");
  // query is "hello" for /api/search?query=hello
  return Response.json({ query });
}

Without NextRequest, you'd have to do new URL(request.url).searchParams yourself — not hard, but nextUrl saves the boilerplate and is worth reaching for by default.

Reading Cookies

You have three legitimate ways to read cookies in a Route Handler, and which one you reach for depends on whether you also need to write cookies.

The cookies() function from next/headers gives you a read/write cookie jar shared with the rest of the App Router's request-scoped APIs:

import { cookies } from "next/headers";

export async function GET(request: Request) {
  const cookieStore = await cookies();
  const token = cookieStore.get("token");

  return new Response("Hello, Next.js!", {
    status: 200,
    headers: { "Set-Cookie": `token=${token?.value}` },
  });
}

Note that cookies() itself is read-focused inside a Route Handler in the sense that mutating the cookie jar doesn't automatically attach Set-Cookie headers to your response the way it does when you call cookieStore.set() inside a Server Action — you still need to add the header yourself if you're building the Response manually. If you'd rather skip that mental overhead, read straight off NextRequest.cookies, which is a plain read-only accessor over the incoming Cookie header:

import { type NextRequest } from "next/server";

export async function GET(request: NextRequest) {
  const token = request.cookies.get("token");
}

Reading Headers

Same pattern, same tradeoff. headers() from next/headers is read-only inside a Route Handler — to send different headers back, construct a new Response with a headers init object rather than trying to mutate what headers() gave you:

import { headers } from "next/headers";

export async function GET(request: Request) {
  const headersList = await headers();
  const referer = headersList.get("referer");

  return new Response("Hello, Next.js!", {
    status: 200,
    headers: { referer: referer ?? "" },
  });
}

Or, again, go straight to the Web API on the request object itself: new Headers(request.headers).

Reading the Body: JSON and FormData

Because the request is a real Request, body parsing uses the same methods you already know from fetch:

export async function POST(request: Request) {
  const body = await request.json();
  return Response.json({ received: body });
}

For HTML form submissions or multipart/form-data uploads, use request.formData() instead:

export async function POST(request: Request) {
  const formData = await request.formData();
  const name = formData.get("name");
  const email = formData.get("email");
  return Response.json({ name, email });
}

One practical note the docs mention only in passing: everything that comes out of FormData is a string (or a File), never a number, boolean, or nested object. If your form has a quantity field or a checkbox, you're responsible for coercing it. A validation library built for this shape of data — zod-form-data is the one most people reach for — will save you from writing Number(formData.get('quantity')) scattered across every handler and will give you a single place to define what a "valid" submission looks like.

There's also no equivalent of the old bodyParser config you might remember from API Routes. request.json() and request.formData() just work, with no size limits or parser middleware to configure up front — which is one less footgun compared to the Pages Router, where forgetting to disable the default body parser for streaming uploads was a classic mistake.

Returning Responses

You return a Response (or NextResponse, which extends it with a few conveniences of its own, most notably around redirects and cookies). Response.json() is the shortcut you'll use for almost every JSON API:

export async function GET() {
  const items = await db.item.findMany();
  return Response.json(items);
}

For anything with a custom status code, headers, or a non-JSON body, construct Response directly:

return new Response(JSON.stringify({ error: "Not found" }), {
  status: 404,
  headers: { "Content-Type": "application/json" },
});

For redirects specifically, don't try to hand-roll a 3xx Response — use the redirect() function from next/navigation, which throws a special error internally that Next.js catches to short-circuit the response with the correct status and Location header:

import { redirect } from "next/navigation";

export async function GET(request: Request) {
  redirect("https://nextjs.org/");
}

Dynamic Segments and Typed Params

Route Handlers pick up dynamic segments exactly like pages do. The catch that trips up almost everyone coming from an older Next.js version: params is a Promise, not a plain object, and you must await it before reading anything off it.

export async function GET(
  request: Request,
  { params }: { params: Promise<{ slug: string }> },
) {
  const { slug } = await params;
  return Response.json({ slug });
}

This became a Promise in Next.js 15 specifically so the framework can start rendering/handling a route before every dynamic value along the way has necessarily resolved — a codemod exists if you're upgrading an older project and need to update every handler mechanically rather than by hand.

If you're on TypeScript, there's a much nicer option than typing params by hand for every route: the globally available RouteContext helper, generated automatically from your actual route literals during next dev, next build, or next typegen.

import type { NextRequest } from "next/server";

export async function GET(_req: NextRequest, ctx: RouteContext<"/users/[id]">) {
  const { id } = await ctx.params;
  return Response.json({ id });
}

RouteContext<'/users/[id]'> is checked against your project's real route tree, so if you rename the folder from [id] to [userId] and forget to update the type, TypeScript catches the mismatch instead of letting a silently-wrong params.id ship to production. It doesn't need an import — the type generation step adds it to the global scope, which feels a little unusual the first time you see it referenced with no corresponding import type line above it, but it's intentional.

Caching: The Part Everyone Gets Wrong

This is the single biggest behavioral difference from what long-time Next.js users expect, and the docs are correct but easy to skim past: Route Handlers are not cached by default. Every request to a GET handler re-runs your function, full stop, unless you explicitly opt in.

That default flipped in Next.js 15 — versions before that cached GET handlers by default unless you opted out, which is the exact opposite of the current behavior. If you're reading a tutorial, Stack Overflow answer, or even an older cached page from search results that says otherwise, check the date before you trust it.

To opt a GET handler into static caching, use the route segment config option dynamic:

export const dynamic = "force-static";

export async function GET() {
  const res = await fetch("https://data.mongodb-api.com/...", {
    headers: {
      "Content-Type": "application/json",
      "API-Key": process.env.DATA_API_KEY,
    },
  });
  const data = await res.json();

  return Response.json({ data });
}

Two things worth flagging that aren't obvious from that snippet alone. First, this only applies to GET — if you export POST, PUT, DELETE, or any other method in the same file, those are never cached, even sitting right next to a cached GET. Second, revalidate works here too, exactly like it does on pages, so a time-based cache is just as available as a fully static one:

export const revalidate = 60;

export async function GET() {
  const data = await fetch("https://api.vercel.app/blog");
  const posts = await data.json();
  return Response.json(posts);
}

If You're on Cache Components

Projects with the newer cacheComponents flag enabled (an experimental, opt-in model still rolling out — most existing production apps are not running it yet, so don't be surprised if this section doesn't apply to the project in front of you) get a different mental model entirely: GET Route Handlers behave like ordinary UI routes. They execute at request time by default, but Next.js will prerender them at build time if they don't touch anything uncached or request-specific, and you wrap any data access you do want cached in a use cache function.

import { cacheLife } from "next/cache";

export async function GET() {
  const products = await getProducts();
  return Response.json(products);
}

async function getProducts() {
  "use cache";
  cacheLife("hours");
  return await db.query("SELECT * FROM products");
}

The one hard rule here: use cache cannot go directly inside the Route Handler body — pull the cached logic out into its own function first. Anything that reads headers(), cookies(), connection(), request properties, or does something non-deterministic (a raw Math.random() call is enough) will stop prerendering and fall back to per-request execution the moment the build encounters it, which is exactly the safety net you want: you don't have to manually mark a route dynamic just because it happens to read a header somewhere deep in a helper function.

Streaming Responses

Because a Route Handler returns a real Response, you have full access to ReadableStream, which makes streaming just as native here as it is anywhere else on the Web platform. This is the mechanism behind streaming an LLM's token-by-token output back to a client, but it's equally useful for any long-running response you'd rather flush incrementally than buffer entirely in memory first:

function iteratorToStream(iterator: AsyncGenerator<Uint8Array>) {
  return new ReadableStream({
    async pull(controller) {
      const { value, done } = await iterator.next();
      if (done) {
        controller.close();
      } else {
        controller.enqueue(value);
      }
    },
  });
}

const encoder = new TextEncoder();

async function* makeIterator() {
  yield encoder.encode("<p>One</p>");
  await new Promise((r) => setTimeout(r, 200));
  yield encoder.encode("<p>Two</p>");
  await new Promise((r) => setTimeout(r, 200));
  yield encoder.encode("<p>Three</p>");
}

export async function GET() {
  return new Response(iteratorToStream(makeIterator()));
}

If you're building an AI chat feature specifically, reach for the Vercel AI SDK before hand-rolling this — streamText() combined with a streaming response helper handles backpressure, chunk framing, and provider-specific formatting for you, and the raw ReadableStream pattern above is really the fallback for the cases the SDK doesn't cover.

CORS, Webhooks, and Non-UI Responses

CORS headers are just headers — set them on the Response like anything else:

export async function GET(request: Request) {
  return new Response("Hello, Next.js!", {
    status: 200,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
    },
  });
}

The trap here connects back to the free OPTIONS handler mentioned earlier: if you don't define your own OPTIONS export, the one Next.js generates for you handles the Allow header correctly but has no idea about your custom CORS headers, since it never runs your GET function's body. A cross-origin fetch with custom headers or a non-simple method will send a preflight OPTIONS request first, get a response with no Access-Control-Allow-* headers on it, and fail before your GET ever runs — even though GET itself is configured correctly. If you need CORS on more than one route, it's usually less error-prone to centralize it in Proxy or in the headers key of next.config.js than to repeat this block in every route.ts file.

Webhooks are one of the most common real-world uses for Route Handlers, and the important detail is almost always about how you read the body, not the handler shape itself. Payment providers and most webhook senders sign the payload using the raw, unparsed body — if you call request.json() first and try to verify a signature against the re-serialized object, the signature will not match, because JSON.stringify does not guarantee byte-for-byte fidelity with whatever the sender originally serialized. Read the raw text (or bytes) first, verify the signature against that, and only parse it afterward:

export async function POST(request: Request) {
  const rawBody = await request.text();

  const signature = request.headers.get("x-signature");
  // verify(rawBody, signature, process.env.WEBHOOK_SECRET) — pseudocode
  // Reject early with a 400 if verification fails, before parsing anything.

  const payload = JSON.parse(rawBody);
  // ...process payload

  return new Response("Success!", { status: 200 });
}

Non-UI responses — an RSS feed, a plaintext file, a custom XML document — are equally natural, since you control the Content-Type header directly:

export async function GET() {
  return new Response(
    `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
  <title>My Blog</title>
  <link>https://example.com</link>
  <description>Latest posts</description>
</channel>
</rss>`,
    { headers: { "Content-Type": "text/xml" } },
  );
}

Worth knowing before you build one yourself: sitemap.xml, robots.txt, and Open Graph/Twitter images all have dedicated, purpose-built file conventions (sitemap.ts, robots.ts, opengraph-image.tsx) that handle the content-type and caching concerns for you. Reach for a generic Route Handler only when what you're generating doesn't already have a metadata-file convention of its own.

Route Resolution Rules

A route.js file is the lowest-level routing primitive the App Router has — lower than page.js, in the sense that it doesn't participate in layouts, nested UI, or client-side navigation at all. A request either matches a route segment's route.js or it doesn't; there's no partial rendering, no shared layout wrapping the JSON response, none of the UI-composition machinery that makes the rest of the App Router pleasant to work with. That's a feature, not a limitation — you want a route handler to be a thin, predictable function, not something that inherits thirty different layout side effects.

The one rule to actually memorize is the conflict rule already mentioned, laid out here for a full reference:

PageRouteResult
app/page.jsapp/route.jsConflict
app/page.jsapp/api/route.jsValid
app/[user]/page.jsapp/api/route.jsValid

Whichever file — page.js or route.js — exists at a given segment claims every HTTP verb for that exact segment. There's no partial ownership where page.js handles GET and a sibling route.js handles POST at the same path.

When Not to Reach for a Route Handler

The docs won't tell you this because it's an opinion, not a spec, but it's worth saying plainly: if the only consumer of your endpoint is your own frontend, and the operation is a mutation (creating a record, updating a setting, sending a message), a Server Action is very often the better tool. A Route Handler forces you to hand-write the request/response contract, the fetch call on the client, and usually a loading/error state around all of it. A Server Action lets you call a server function directly from a form or an event handler, with Next.js handling the network boundary, and it integrates with useActionState and optimistic UI patterns that a Route Handler gives you nothing for out of the box.

Reach for a Route Handler when you actually need a real, addressable HTTP endpoint: a public API other services will call, a webhook receiver, an OAuth callback, a route third-party tooling expects to curl, or a response type (RSS, an image, a redirect chain) that doesn't map cleanly onto "call a server function and get data back." If you're building a backend for your own frontend and nothing else will ever hit the URL, look at Server Actions first and only fall back to a Route Handler if you hit one of its specific limitations (streaming, custom headers, non-JSON content types, or being consumable by clients that aren't your own React app).

Key Takeaways

Route Handlers give the App Router a clean, standards-based way to expose custom HTTP endpoints, built entirely on Request/Response rather than a framework-specific req/res pair. The rules are few, but they're easy to get subtly wrong if you're carrying assumptions over from API Routes or from an older Next.js version.

ConcernWhat to remember
File conventionroute.js/route.ts, exporting GET/POST/etc.; cannot coexist with page.js in the same segment
Unsupported methodsNext.js returns 405 automatically; you don't handle this yourself
Request objectPlain Request by default; type as NextRequest for nextUrl, cookies, and friends
Dynamic paramsparams is a Promise — always await it; use RouteContext<'/path'> for typed access
CachingNot cached by default since Next.js 15; opt in with dynamic = 'force-static' or revalidate, GET only
Cache Components projectsGET handlers behave like normal routes — prerendered unless they touch uncached/runtime data
WebhooksVerify signatures against the raw body via request.text(), before you JSON.parse anything
CORSYour custom OPTIONS export must set CORS headers — the auto-generated one won't
When to skip itSame-app mutations usually belong in a Server Action; Route Handlers are for endpoints other clients or services need to call

Get comfortable with these and Route Handlers stop feeling like a smaller, stranger version of API Routes and start feeling like what they actually are: a thin, portable layer over the Web platform's own request/response model, which happens to live inside the same app directory as the rest of your UI.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper