Skip to content
beginner Phase 7 · Route Handlers and APIs

Creating GET and POST Handlers

Export per-method functions from a route file.

45m
0 problems
Topic Progress 0%

One File, One Method: How app/api/orders/route.ts Becomes an Endpoint

You have a page at app/orders/page.tsx that renders order rows, and the client now needs to create an order without a full navigation. In the Pages Router you would have opened pages/api/orders.ts. In the App Router the API lives in the same tree as the page: create app/api/orders/route.ts and the URL /api/orders exists. The folder structure is the routing table, which means an API surface grows exactly where the UI that calls it already lives.

A route file is nothing but a module whose exports are named after HTTP methods, uppercase:

// app/api/orders/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const orders = await db.orders.findMany();
  return NextResponse.json(orders);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const order = await db.orders.create({ data: body });
  return NextResponse.json(order, { status: 201 });
}

Valid export names are GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. If a client sends a method you did not export, Next.js answers 405 Method Not Allowed and fills in an Allow header listing what the file does export — you rarely hand-write that response. There is no default export in a route file; exporting one is a build error, because a default export says nothing about which method it serves.

Dynamic segments follow the same convention as pages. app/api/orders/[id]/route.ts receives the captured value in the second argument, and here the version matters: in Next.js 15 and later params is a Promise you must await, and the synchronous form from 14 and earlier is gone in 16.

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;   // Next 15+; plain object in <= 14
  const order = await db.orders.findUnique({ where: { id } });
  if (!order) return NextResponse.json({ error: 'not found' }, { status: 404 });
  return NextResponse.json(order);
}

Two folder rules bite people during their first week. First, a segment may hold page.tsx or route.ts, never both — the build fails with a conflict error, because one path cannot simultaneously mean "render HTML" and "run this handler." Give the API its own folder (app/api/...) or use route groups like app/(shop)/api/.... Second, folders that only organize code do not affect the URL: (dashboard) groups collapse out of the path, _scratch private folders are excluded from routing entirely, and a [id] folder contributes one path segment. So the file location is a precise contract: app/api/orders/[id]/route.ts is reachable only at /api/orders/<id>.

The handler's first argument is a NextRequest, Next.js's typed extension of the standard Web Request: it adds nextUrl (a parsed URL with helpers) and a parsed cookies accessor. You may type the parameter as plain Request when you only need the platform API. Handlers are ordinary server code, not sandboxed snippets — they read process.env secrets, hold database connections, and import shared library modules, and because they live on your origin there is no CORS negotiation for your own frontend to call them. Before any UI exists, prove the contract with curl -i localhost:3000/api/orders: the status line and headers you see are exactly what a browser's fetch will get.

Three Ways to Return a Response and What Each One Sets for You

A handler must resolve to something the browser can interpret as an HTTP response. Route handlers accept three shapes, and choosing between them is choosing how much you want to control.

A plain object or array is the shortcut. return { ok: true } is serialized to JSON with status 200 and Content-Type: application/json. It is fine for the happy path and useless the moment you need a 201, a custom header, or an empty body — there is nowhere on a bare object to put them.

NextResponse.json(body, init) is the JSON case with full control:

export async function POST(request: NextRequest) {
  const email = await request.json().then(b => b.email);
  if (!email) {
    return NextResponse.json(
      { error: 'email required' },
      { status: 400, headers: { 'x-request-id': crypto.randomUUID() } },
    );
  }
  const user = await createUser(email);
  return NextResponse.json(user, { status: 201 });
}

It sets the JSON content type for you and accepts a standard ResponseInit, so status and headers work exactly as they do on the web platform.

new Response(body, init) (or NextResponse, which extends it) covers everything else: streams, redirects, downloads, no content at all.

// 204 — body must be null
return new NextResponse(null, { status: 204 });

// redirect: 307 keeps the method and body, 303 forces a GET
return NextResponse.redirect(new URL('/login', request.url));
return NextResponse.redirect(new URL('/done', request.url), 303);

// file download
return new NextResponse(pdfBytes, {
  headers: {
    'content-type': 'application/pdf',
    'content-disposition': 'attachment; filename="report.pdf"',
  },
});

Because NextResponse is a Response, helpers come along: response.cookies.set('session', token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' }) writes a Set-Cookie header without manual string formatting. headers.append(...) adds a second value where headers.set(...) would overwrite — relevant for Set-Cookie and Vary.

Two runtime rules cause confusing runtime errors. A response with status 204, 205, or 304 must have a null body; passing any body throws TypeError: Response with null body status cannot have body. And a redirect status must be 301, 302, 303, 307, or 308 — NextResponse.redirect defaults to 307, which preserves the method, so a POST that redirects with 307 re-submits the body to the new URL. Use 303 when a POST should follow up as a plain GET.

Status codes are also where API contracts are enforced. An interview favorite: returning 200 with { error: '...' } in the body trains clients to parse bodies for failures. Pick one convention — non-2xx status plus a structured error body — and every fetch caller can if (!res.ok) once and handle the rest in one place.

NextResponse is a subclass of the standard Response, so anything the Web platform offers works in handlers too: Response.json(body, init) and Response.redirect(url, status) are plain platform calls, while the cookie helpers are the Next-specific layer on top. The body you pass may be a string, Blob, ArrayBuffer, URLSearchParams, or a ReadableStream — the streaming case is covered in Streaming Responses — and anything else must be serialized before you pass it, which is exactly why NextResponse.json exists instead of relying on implicit coercion of arbitrary objects.

A small helper keeps the error shape uniform across every file:

// lib/api.ts
export function apiError(status: number, code: string, message: string) {
  return NextResponse.json({ error: { code, message } }, { status });
}
// usage in any handler:
return apiError(409, 'duplicate_email', 'That email is already registered');

The status carries the machine-readable category — 409 means conflict to every HTTP client — while the code gives the UI something specific to branch on: show a "use a different address" message instead of a generic retry button. Fifty handlers each inventing their own { error: ... } shape is a support-ticket generator; one helper prevents it.

Build-Time, Cached, or Per Request: Deciding When Your GET Actually Runs

Writing the handler is the easy half. The question that decides whether your API is fast, stale, or correct is: when does this function run? Route handlers can execute at build time, serve from a cache, or run on every request, and the answer depends on both your config and your Next.js version.

For GET specifically, the default changed:

Next.js version Default for exported GET
14 and earlier cached: evaluated at build time unless it uses request data
15 and later not cached: runs per request unless you opt in

So a handler that reads no request state — a static list of countries, a build-computed manifest — was frozen into the build output on 14 but hits your database on every call on 15 until you declare otherwise. Read the next build output: ● (ISR/static) means the route was prerendered or cached, ƒ means dynamic.

Route segment config is the declaration mechanism, exported from the same file:

// app/api/config/route.ts
export const dynamic = 'force-static'; // evaluate at build, serve from cache
export const revalidate = 60;          // re-run at most every 60 seconds
export const runtime = 'nodejs';        // or 'edge' for the Edge runtime
export const maxDuration = 30;          // serverless execution ceiling (seconds)

export async function GET() {
  return NextResponse.json(await loadStaticConfig());
}

revalidate = 60 gives an ISR-style handler: the first request after the window expires receives the stored response while Next regenerates in the background, so nobody waits for the rebuild. dynamic = 'force-static' opts a 15+ handler into the old cached behavior explicitly. Anything that touches per-request state — cookies(), headers(), the query string, await request.json() — makes the handler dynamic regardless of exports, because the output could differ per caller.

Non-GET methods are never cached; there is no version where POST is stored. And note the asymmetry that confuses people: HEAD is derived from your GET automatically, but a cached GET means the cached HEAD too.

runtime = 'edge' swaps Node.js for the Web API surface: fetch, Request, Response, URL, and Web crypto work; fs, native crypto modules, and most npm packages that ship native code do not. Edge costs less cold-start latency and loses the Node ecosystem — pick it for thin, geo-distributed endpoints, keep Node for database drivers.

dynamic = 'error' is the strictest setting and the most valuable in CI: any request-time API fails the build outright instead of silently flipping the route dynamic, so an accidental cookies() read in a helper cannot quietly destroy the caching plan months later. One deployment detail explains most "stale in staging" reports: where the cached response lives. Self-hosted, it sits in .next per instance, so a fleet of containers may regenerate independently; a platform with a shared cache layer reuses one entry across instances. Same config, different blast radius — know which one you are deploying to.

This is the layer interviews probe: they hand you a handler, ask "does this run once or per request, and on which version?" Answer by reading the exports, the dynamic APIs it touches, and the segment config — in that order. For how a handler reads the incoming request itself, continue with Handling Request Methods.

Quiz

1. app/api/orders/route.ts exports only GET and POST. A client sends PUT /api/orders. What does Next.js return?

Question 1 options

2. In a Next.js 15 route handler at app/api/orders/[id]/route.ts, how do you read the id?

Question 2 options

3. You add page.tsx and route.ts to the same app/webhooks/ folder. What happens?

Question 3 options

4. An app upgraded from Next 14 to 15 has a GET handler with no config exports that returns a small static list. What changes?

Question 4 options

Flashcards

Question

How does a file become an API endpoint in the App Router?

Answer

Create route.ts (or route.js) in a folder under the app directory; export async functions named GET, POST, PUT, PATCH, DELETE, HEAD, or OPTIONS. The folder path becomes the URL. No default export is allowed, and the segment cannot also contain page.tsx.

Question

What does Next.js return when a client calls a method the route file does not export?

Answer

405 Method Not Allowed, with an Allow header enumerating the exported methods. 404 is reserved for paths that match no route at all.

Question

What are the three legal return shapes from a route handler, and what does each give you?

Answer

A plain object/array — auto-serialized as JSON with status 200; NextResponse.json(body, init) — JSON with explicit status and headers; a Response/NextResponse instance — full control including null bodies (204), redirects, streams, and custom headers/cookies.

Question

How did GET route handler caching default change between Next 14 and 15?

Answer

Next 14 and earlier: GET handlers were cached (build-time or revalidated) unless they used request data. Next 15+: GET handlers are uncached and run per request by default; opt in with export const dynamic = 'force-static' or revalidate = N. Non-GET methods were never cached.

Question

Which route segment config exports control when and where a handler runs?

Answer

dynamic ('force-static' | 'force-dynamic' | 'error' | 'auto') decides per-request vs cached; revalidate (seconds) sets background regeneration for cached output; runtime ('nodejs' | 'edge') picks the execution environment; maxDuration caps serverless execution time.

Revision Notes

Key Takeaways

  • 1. Route handlers are folders of method-named exports; the directory tree is the API contract, and page.tsx plus route.ts can never share a segment
  • 2. 405 with an Allow header — not 404 — is the correct answer for a missing method, which is exactly what interviewers test
  • 3. Return a Response/NextResponse whenever status, headers, empty bodies, or redirects matter; plain objects only buy you the 200 happy path
  • 4. GET handler caching defaulted to cached on Next 14 and uncached on 15 — unexamined version defaults are the root cause of surprise latency or staleness
  • 5. Segment config (dynamic, revalidate, runtime, maxDuration) declares when and where code runs; request-dependent reads override it toward dynamic

Interview Tips

  • • "Difference between a page and a route handler at the same path?" — page returns HTML/RSC for navigations, route.ts answers raw HTTP; they conflict in one segment, so the API gets its own folder
  • • "What status do you return for a missing method / missing resource?" — 405 + Allow for the method, 404 with a structured body for the resource; mention 401/403 for auth in between
  • • "Is a route handler cached?" — show the decision order: version default → dynamic APIs it touches → exported segment config → next build markers; never guess from the code alone
  • • "Edge or Node runtime for this endpoint?" — name the trade-off: Edge gives low latency and Web APIs only; Node gives database drivers and native modules; data access usually decides it

Cheat Sheet

Creating GET and POST Handlers

File → URL: app/api/orders/[id]/route.ts → /api/orders/:id. Groups (x) vanish, _x folders are excluded, [id] is one segment.

Exports: GET POST PUT PATCH DELETE HEAD OPTIONS — named, uppercase, async. No default export. Missing method → 405 + Allow header.

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> },   // Next 15+ (sync form removed in 16)
) {
  const { id } = await params;
  return NextResponse.json(await db.find(id));
}

Return shapes:

Shape Status Headers Use when
plain object 200 JSON content-type: application/json happy path only
NextResponse.json(b, init) yours yours normal API responses
new Response(body, init) yours yours 204, redirects, downloads, streams

When GET runs:

Version Default Opt in/out with
≤ 14 cached (build-time) dynamic = 'force-dynamic'
≥ 15 per request dynamic = 'force-static', revalidate = N
Any request read (cookies, headers, query) forces dynamic. Non-GET is never cached. Verify with the ●/ƒ markers in next build.

Runtime: nodejs = full npm + native modules; edge = Web APIs only (fetch, crypto.subtle — no fs, no native addons).

Continue Learning

Before You Start

Related Reading