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.
| Task | Code |
|---|---|
| Return JSON | return Response.json(data, { status: 200 }) |
| Read a JSON body | const body = await request.json() |
| Read form data | const form = await request.formData() |
| Read ?q= | request.nextUrl.searchParams.get("q") |
| Dynamic segment | const { id } = await params |
| Cookies / headers | await 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.
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.
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 });
}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 });
}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 });
}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 });
}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/api/x.ts | app/api/x/route.ts | |
|---|---|---|
| Handler | export default function handler(req, res) | export async function GET(request) |
| Send JSON | res.status(200).json(data) | return Response.json(data) |
| Method switch | if (req.method === "POST") | a separate exported POST function |
| Body parsing | req.body (bodyParser config) | await request.json() or request.formData() |
| Query string | req.query | request.nextUrl.searchParams |
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.
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.
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.
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.
GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS. Calling a method you did not export returns 405 Method Not Allowed.
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.
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.
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.
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.