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.tsmakes its folder an HTTP endpoint and replaces anypage.tsxat 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.jsonsets the content type for you;NextResponse.redirectand.rewritecover the other common cases.- Handlers are dynamic as soon as they read the request. Set a
Cache-Controlheader 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?
| Need | Use |
|---|---|
| A form or button that mutates data in this app | Server action |
| A JSON API for another app, a mobile client or a partner | Route handler |
| A webhook from a third party | Route handler |
| A file download or a streamed response | Route handler |
| A form that must still work with JavaScript disabled | Server action passed to the form's action prop |
| A scheduled job triggered by an external caller | Route 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.Related
Server actions and forms in depth Middleware, headers, cookies and sessions
Last refreshed 2026-09-18.