Next.js API Routes: Route Handler Examples

In the App Router, an API endpoint is a route.ts file inside app, for example app/api/todos/route.ts. Export async functions named after HTTP methods such as GET and POST; each receives a Request and returns a Response, usually Response.json(). Dynamic segments arrive as a params promise you await. GET handlers are dynamic by default since Next.js 15.

Route Handler basics
TaskCode
Return JSONreturn Response.json(data, { status: 200 })
Read a JSON bodyconst body = await request.json()
Read form dataconst form = await request.formData()
Read ?q=request.nextUrl.searchParams.get("q")
Dynamic segmentconst { id } = await params
Cookies / headersawait cookies(), await headers() from next/headers

Updated

Pages Router API routes used req and res objects. Route Handlers use the standard Web Request and Response instead, which trips up anyone copying older answers. Below are the patterns you will need for a real app, each written for the current App Router, plus a small tester that shows the status codes a validating POST should send back.

POST tester

Edit a JSON body, choose whether the caller is signed in, and see the status code and response the validating POST example would send back. PUT shows the 405 Next.js sends for a method the file does not export.

Route Handler examples

GET with a search query

app/api/todos/route.ts. The q parameter filters the list; an empty query returns everything.

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

const todos = [
  { id: 1, title: "Write the brief", done: true },
  { id: 2, title: "Ship the page", done: false },
];

export async function GET(request: NextRequest) {
  const q = request.nextUrl.searchParams.get("q")?.toLowerCase() ?? "";
  const items = todos.filter((t) => t.title.toLowerCase().includes(q));
  return Response.json({ items });
}

POST with Zod validation and an auth check

Returns 401 without a session, 400 with field errors for a bad body, and 201 with the new record otherwise. getSession stands in for your auth library.

// app/api/todos/route.ts (continued)
import { z } from "zod";
import { getSession } from "@/lib/session";

const NewTodo = z.object({
  title: z.string().trim().min(1, "Title is required").max(120),
});

export async function POST(request: Request) {
  const session = await getSession();
  if (!session) return Response.json({ error: "Sign in first" }, { status: 401 });

  const parsed = NewTodo.safeParse(await request.json().catch(() => null));
  if (!parsed.success) {
    return Response.json(
      { error: "Invalid body", fields: parsed.error.flatten().fieldErrors },
      { status: 400 },
    );
  }

  const todo = { id: Date.now(), ownerId: session.userId, done: false, ...parsed.data };
  return Response.json(todo, { status: 201 });
}

Dynamic segment with awaited params

app/api/todos/[id]/route.ts. params is a promise in Next.js 15 and later.

// app/api/todos/[id]/route.ts
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  const todo = await db.todo.findUnique({ where: { id: Number(id) } });
  if (!todo) return Response.json({ error: "Not found" }, { status: 404 });
  return Response.json(todo);
}

export async function DELETE(
  _request: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  await db.todo.delete({ where: { id: Number(id) } });
  return new Response(null, { status: 204 });
}

CORS for one trusted origin

Answers the browser's preflight and the real request with the same allowed origin.

const ORIGIN = "https://app.example.com";
const cors = {
  "Access-Control-Allow-Origin": ORIGIN,
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
};

export function OPTIONS() {
  return new Response(null, { status: 204, headers: cors });
}

export async function GET() {
  return Response.json({ ok: true }, { headers: cors });
}

Calling the handler from a Client Component

The fetch helper reads the status code first, so the form can show field errors from a 400.

async function createTodo(title: string) {
  const res = await fetch("/api/todos", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title }),
  });
  const data = await res.json();
  if (!res.ok) throw Object.assign(new Error(data.error), { status: res.status, fields: data.fields });
  return data;
}

Pages Router API routes vs Route Handlers

Same job, different APIs
pages/api/x.tsapp/api/x/route.ts
Handlerexport default function handler(req, res)export async function GET(request)
Send JSONres.status(200).json(data)return Response.json(data)
Method switchif (req.method === "POST")a separate exported POST function
Body parsingreq.body (bodyParser config)await request.json() or request.formData()
Query stringreq.queryrequest.nextUrl.searchParams

File layout

The folder path is the URL. app/api/todos/route.ts answers /api/todos, and app/api/todos/[id]/route.ts answers /api/todos/42. A catch-all folder like [...slug] passes an array of segments.

Route Handlers share segment config with pages, so the same dynamic, revalidate and runtime exports apply to them.

Status codes that clients understand

Send 201 after creating something, 400 when the input is wrong, 401 when nobody is signed in, 403 when the signed-in user lacks permission, 404 when the record does not exist and 405 for unsupported methods. Front ends can then branch on the code instead of parsing error text.

Return a consistent JSON shape for errors, such as { error: string, fields?: object }, so one fetch helper can handle them all.

Security checklist

Verify the session, check that the user owns the record they are changing, validate the payload, and never echo secrets or full user rows back. The official authentication guide shows this two-step pattern of a 401 for no session and a 403 for the wrong role.

Set CORS headers only on handlers that other origins really need to call, and list the specific origins rather than an asterisk whenever cookies are involved.

How it works

  1. Fill in the fake request body in the tester and choose whether the caller is signed in.
  2. The tester runs the same validation rules as the POST example on this page, in your browser.
  3. Read the status code and JSON it returns, then change a field to see a 400, 401 or 201.
  4. Copy the example handler and swap the in-memory list for your database.

Tips

Frequently asked questions

Are Next.js API routes deprecated?

pages/api routes still work in the Pages Router. In the App Router the equivalent is a Route Handler, a route.ts file that uses Web Request and Response objects and needs no bodyParser configuration.

Which HTTP methods can a route.ts export?

GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS. Calling a method you did not export returns 405 Method Not Allowed.

Why do I have to await params?

Since Next.js 15, the context argument's params is a promise. Write const { id } = await params. The RouteContext helper type, generated by next dev, next build or next typegen, gives you typed params from the route path.

Are GET Route Handlers cached?

Not by default any more. Next.js 15 changed GET handlers from static to dynamic. Opt back into caching with export const dynamic = 'force-static' or a revalidate value. With Cache Components enabled, put use cache in a helper function the handler calls, not in the handler body.

Should I use a Route Handler or a Server Action?

Use a Server Action for mutations triggered by your own forms and buttons. Use a Route Handler when something outside your React tree calls the URL: a mobile app, a webhook, a cron job or a third-party script.

Related: Next.js proxy (middleware) examples, Next.js authentication options, JavaScript fetch and async/await, HTTP status codes, JSON formatter.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap