What you'll learn

By the end of this you'll know how to create route handlers in the Next.js App Router, when to reach for them instead of Server Actions, how to read and validate request data safely, and what keeps your secrets out of the browser bundle. You'll also know the exact mistakes people make on their first day with route.ts files — so you don't have to make them yourself.

If you've used pages/api/ before, route handlers will feel familiar. There are a few differences worth knowing and one sharp edge around caching that trips people up long after they think they've figured this out.

Who this is for

  • You're building a Next.js App Router project and need an HTTP endpoint — for webhooks, third-party integrations, mobile clients, or data that lives on a different origin
  • You've heard "API routes" and "route handlers" used interchangeably and want to know if they're actually the same thing (mostly yes, with caveats)
  • You want to know when a Server Action is the better call instead of a route handler

You can skip this if you're already shipping route.ts files regularly and you're here to look up a specific pattern. Maybe glance at the caching section though — that part bites people even after they think they've got this.

What is a route handler?

A route handler is a file called route.ts (or route.js) inside your app/ directory that exports functions named after HTTP methods — GET, POST, PUT, DELETE, and so on. When a request arrives at that path, Next.js calls the matching function and returns whatever you give back.

Plain English: it's how you build an API endpoint in an App Router project. The response goes back as JSON, a redirect, a stream, or any other HTTP response you want to send.

Simple idea: put route.ts at app/api/hello/route.ts, export a GET function that returns a Response, and /api/hello becomes a working endpoint. That's it.

Prerequisites

  • Comfortable with Next.js App Router basics — layouts, pages, Server Components
  • TypeScript at a working level (you can read types and write simple interfaces)
  • Basic HTTP knowledge — you know what GET and POST mean and what a status code is

Key terms

Route segment — a folder inside app/ that maps to a URL path. app/api/users/ maps to /api/users.

route.ts — the special filename that turns a route segment into an HTTP handler. It can't coexist with page.tsx in the same folder.

Web Request / Response — route handlers use the standard web Request and Response objects, not Express-style req/res.

NextRequest / NextResponse — Next.js extensions that add helpers like request.nextUrl and NextResponse.json(). Optional but handy.

Dynamic segment — a folder named [id] that captures a URL parameter. Works the same as in page routes.

dynamic exportexport const dynamic = "force-dynamic" at the top of a route file, which opts out of static caching for GET handlers.

Setup from zero

Step 1 — Create the folder and file

Create app/api/posts/route.ts. No config, no boilerplate beyond the file itself. The filename route.ts is what activates the handler — Next.js recognises it automatically.

Step 2 — Export a GET function

// app/api/posts/route.ts
export async function GET() {
  const posts = [{ id: 1, title: "Hello world" }];
  return Response.json(posts);
}

Hit /api/posts in the browser or with curl. You'll get JSON back. That's the entire basic shape — a function, a Response, done.

Step 3 — Read the request

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

export async function GET(request: NextRequest) {
  const tag = request.nextUrl.searchParams.get("tag") ?? "";
  const posts = POSTS.filter((p) => p.tag === tag || tag === "");
  return Response.json(posts);
}

Little tip: request.nextUrl parses the full URL for you, including search params. It saves you from manually constructing a URL object from request.url.

Step-by-step

Reading a POST body

export async function POST(request: Request) {
  const body = await request.json();
  // body is unknown — validate before trusting anything
  if (!body.title || typeof body.title !== "string") {
    return Response.json({ error: "title is required" }, { status: 400 });
  }
  return Response.json({ created: true }, { status: 201 });
}

Always validate. request.json() parses JSON, it doesn't check types. A bad client can send whatever it wants, so treat the body as untrusted input.

Dynamic segments

// app/api/posts/[id]/route.ts
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  return Response.json({ id });
}

The second argument is the route context. In recent Next.js versions params is a Promise — you need to await it. Check your version if the types look off; this changed between releases.

Setting headers and status

// Verbose but clear
return new Response(JSON.stringify({ ok: true }), {
  status: 200,
  headers: { "Content-Type": "application/json" },
});

// Shorter with NextResponse
import { NextResponse } from "next/server";
return NextResponse.json({ ok: true }, { status: 200 });

Working examples

Webhook receiver

// app/api/webhook/route.ts
export async function POST(request: Request) {
  const sig = request.headers.get("x-signature");
  if (!sig) return new Response("Missing signature", { status: 400 });

  const body = await request.text();
  const valid = verifySignature(body, sig, process.env.WEBHOOK_SECRET!);
  if (!valid) return new Response("Invalid signature", { status: 401 });

  const event = JSON.parse(body);
  await processEvent(event);
  return new Response("ok");
}

Little tip: use request.text() instead of request.json() for webhooks. You need the raw string to verify the HMAC signature before you parse anything.

Proxying a third-party API

// app/api/weather/route.ts
import { type NextRequest } from "next/server";

export const dynamic = "force-dynamic";

export async function GET(request: NextRequest) {
  const city = request.nextUrl.searchParams.get("city") ?? "London";
  const res = await fetch(
    `https://api.weather.example.com/v1/current?city=${city}`,
    { headers: { Authorization: `Bearer ${process.env.WEATHER_API_KEY}` } }
  );
  const data = await res.json();
  return Response.json(data);
}

The API key never reaches the browser — it lives in process.env on the server. That's one of the main reasons to use a route handler rather than calling a third-party API directly from a client component.

Patterns / when to use

Use route handlers when:
- An external service needs to call your app (webhooks, OAuth callbacks, payment events)
- You're serving data to a mobile app or a different frontend origin
- You need to proxy a secret API key
- You want fine-grained control over response headers, status codes, or streaming

When NOT to use — if you're handling a form mutation from your own Next.js pages, a Server Action is usually cleaner. No URL to manage, no JSON serialisation, progressive enhancement built in. And they're easier to type-check end-to-end. Route handlers and Server Actions coexist fine — pick the right tool per use case.

Core idea: route handlers are for HTTP surface that other things call. Server Actions are for mutations triggered by your own UI.

Common mistakes

Returning a plain object — unlike Express handlers, you can't just return { data }. You must return a Response (or use Response.json() / NextResponse.json()). Return a plain object and Next.js will throw.

Forgetting caching on GET — static GET handlers are cached at build time by default in some configurations. If your handler reads live data, add export const dynamic = "force-dynamic" at the top of the file.

Putting secrets in client components — route handlers run only on the server, so process.env.MY_SECRET stays there. If you replicate the same call in a client component it will fail or, worse, leak the key to the browser.

File in the wrong placeroute.ts and page.tsx can't share a folder. Next.js will throw a build error. Move the handler into a subfolder, typically under app/api/.

Troubleshooting

405 Method Not Allowed — you made a GET request but only exported a POST (or vice versa). Add the missing export.

Body is undefined or empty — make sure you await request.json(). The request.body property is a raw ReadableStream; you rarely want to touch it directly.

Params not available — on newer Next.js, params is async. Try const { id } = await params instead of const { id } = params.

Stale data in GET — add export const dynamic = "force-dynamic" to the file, or add { cache: "no-store" } to any fetch calls inside the handler.

JSON parse error on POST — if the client sends a malformed body, request.json() will throw. Wrap it in a try/catch and return a 400 response.

Checklist

  • [ ] File is named route.ts (not api.ts or handler.ts)
  • [ ] Exported function name matches the HTTP method (GET, POST, PUT, etc.)
  • [ ] Returns a Response or NextResponse — not a plain object
  • [ ] POST body is validated before being used
  • [ ] Secrets come from process.env, never hardcoded
  • [ ] No page.tsx in the same folder
  • [ ] Dynamic param access uses await params if your Next.js version requires it
  • [ ] GET handler has export const dynamic = "force-dynamic" if it reads live data

Practice task

Build a small /api/quotes/[id] route handler. When no id is given — or id is "random" — return a random quote from a hardcoded array. When id is a valid index, return that specific quote with a 200. Return a 404 with an error message for out-of-range indexes. Then add a POST to /api/quotes that accepts { author, text }, validates that both fields are non-empty strings, and returns the new quote with a 201. Keep data in a module-level array — no database needed for this exercise.

FAQ

Can I use route handlers and Server Actions in the same project?
Yes. They serve different purposes and don't conflict. A route handler is an HTTP endpoint; a Server Action is a function called by your own UI.

Do route handlers run at the edge?
Only if you export export const runtime = "edge". The default is the Node.js runtime.

Can I read cookies or sessions?
Yes. Import cookies from "next/headers" — the same helper used in Server Components. For full session management you'd pair it with something like iron-session or a JWT library.

What's the difference between NextRequest and Request?
NextRequest adds .nextUrl (a parsed URL with query-param helpers) and a few other extras. For simple handlers the standard Request type is fine.

What to learn next

  • Server Actions — the form-mutation counterpart; runs on the server, called by your own UI, no URL required
  • Middleware — runs before any route, the right place for auth checks and redirects
  • Next.js caching — understanding dynamic, revalidate, and no-store becomes important once you're shipping production handlers
  • [App Router fundamentals](/developers/nextjs/nextjs-app-router-guide)
  • [React Server Components explained](/developers/react/react-server-components-explained)
  • [Next.js metadata and SEO](/developers/nextjs/nextjs-metadata-and-seo)

Takeaways

Route handlers live in route.ts files, export functions named after HTTP methods, use standard Request / Response objects, and run exclusively on the server. Secrets in process.env stay there. GET responses cache by default — opt out when you need live data.

If you remember only one thing: when something outside your Next.js app needs to call an endpoint — webhook, mobile client, third-party integration — a route handler is the right tool. For mutations triggered by your own UI, lean toward Server Actions instead.