Route handlers and API endpoints

Write route.ts handlers for each verb, stream a response, verify a webhook safely, and decide when a server action is the better tool.

Verbs, requests and responses

// app/api/guides/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createGuide, listGuides } from '@/lib/guides'

export async function GET(request: NextRequest) {
  const tag = request.nextUrl.searchParams.get('tag')
  const guides = await listGuides(tag)
  return NextResponse.json(guides, {
    headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' }
  })
}

export async function POST(request: NextRequest) {
  const body = await request.json().catch(() => null)
  if (!body?.title) {
    return NextResponse.json({ error: 'title is required' }, { status: 400 })
  }
  const created = await createGuide(body)
  return NextResponse.json(created, { status: 201 })
}
  • A route.ts makes its folder an HTTP endpoint and replaces any page.tsx at the same path — you cannot have both for one URL.
  • Export a function named after the verb: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Anything else returns 405.
  • A dynamic segment passes its resolved parameters as the second argument: { params }: { params: Promise<{ id: string }> }.
  • NextResponse.json sets the content type for you; NextResponse.redirect and .rewrite cover the other common cases.
  • Handlers are dynamic as soon as they read the request. Set a Cache-Control header explicitly when the response is genuinely shareable.

Streaming, webhooks and limits

// app/api/export/route.ts — stream rows as they are produced
export async function GET() {
  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      for await (const row of rows()) {
        controller.enqueue(encoder.encode(JSON.stringify(row) + '\n'))
      }
      controller.close()
    }
  })
  return new Response(stream, { headers: { 'Content-Type': 'application/x-ndjson' } })
}

// app/api/webhooks/stripe/route.ts — verify before you trust
export async function POST(request: Request) {
  const signature = request.headers.get('stripe-signature')
  const raw = await request.text()        // raw body, not json()
  if (!verify(raw, signature)) return new Response('bad signature', { status: 400 })
  await handleEvent(JSON.parse(raw))
  return new Response(null, { status: 204 })
}
  • Return a stream instead of awaiting everything: the client receives bytes while the work continues, which matters for exports and long lists.
  • For a webhook, read the raw text and verify the signature before parsing. Parsing first makes verification impossible and turns the endpoint into an open write.
  • An endpoint that changes data must check the session itself. Nothing about the page that links to it, or a hidden button in the UI, makes it private.
  • Long-running work does not belong in a request: queue it and return 202, because serverless functions have a hard timeout.
  • Validate the payload size and rate-limit at the edge so one client cannot exhaust the function budget.
⚠️
A route handler is a public URL. It is not protected by the page that links to it, by an unguessable path, or by a button hidden in the interface. Authenticate, authorise and validate inside the handler every time.

Handler or server action?

NeedUse
A form or button that mutates data in this appServer action
A JSON API for another app, a mobile client or a partnerRoute handler
A webhook from a third partyRoute handler
A file download or a streamed responseRoute handler
A form that must still work with JavaScript disabledServer action passed to the form's action prop
A scheduled job triggered by an external callerRoute handler
  • Server actions exist so an in-app mutation does not need a hand-written endpoint plus a fetch call. For your own UI they are the default.
  • A route handler is the right answer when the consumer is not your React app, or when the response must be a file, a stream or a specific status code.
  • Both run on the server with the same access to your data layer, and neither is a security boundary on its own.
  • Do not duplicate the logic: put the work in lib/ and call it from the action and from the handler, so validation cannot drift between them.
  • Server actions must be async and can only receive serialisable values. A handler accepts any HTTP payload, which is why third-party callers need one.

FAQ

Why does my handler return 405?
The verb is not exported. A file that only exports GET rejects a POST with 405 by design; add the exported function for each method the endpoint accepts.
Can a page and an endpoint share a path?
No. A route.ts cannot coexist with a page.tsx in the same folder, and the build fails. Put the endpoint under a distinct segment such as /api/guides.

Server actions and forms in depth Middleware, headers, cookies and sessions

Last refreshed 2026-09-18.