The decision rule for when a mutation stays a Server Action and when it needs a Next.js route handler.
For the last few chapters the Server Action has been your default for every mutation, and for an in-app form it is the right tool.
A few cases don’t fit.
A Stripe webhook can’t submit your form.
A mobile app can’t import a Server Action and call it as a function.
A public JSON feed needs a GET, and the action is POST-only.
Each is an edge of what the action can do.
This lesson turns that “action or route.ts?” question into a checklist you can re-run for the rest of the course.
You already know these facts from building actions.
Read them now as the action’s edges: every trigger later in the lesson crosses one of them.
The Server Action envelope
POST-only. The framework wires up a POST. No GET, no PUT, one verb.
React-caller-only. It’s invoked as a typed function from a React component on this same app, Client or Server, never a free-standing URL a third party hits.
Revalidates for free.revalidatePath / revalidateTag / updateTag refresh the affected reads as part of the same request.
~1 MB body cap by default. The request body is capped (serverActions.bodySizeLimit, default '1mb') and buffered into memory before your code runs.
Opaque on the wire. The framework stamps an internal action ID into the bundle. Your call site is typed; the wire format is an implementation detail you never hand-author.
Returns a Result. It hands back the canonical Result shape your form layer reads directly, not an HTTP response.
Degrades gracefully. A <form action> still submits with JavaScript disabled (progressive enhancement).
Any in-app mutation that fits this envelope stays a Server Action; you don’t reach further.
A mobile app.
A Zapier or n8n integration.
A partner’s backend.
A CLI.
A webhook from another of your own services.
None can import a Server Action and invoke it as a function, because they live in a different process, sometimes at a different company.
They need a real, stable HTTP URL.
The action is reachable only from React on this same app, and its action ID is an internal bundle artifact, not a public contract.
The moment the caller is anything else, you need a route handler.
Public REST endpoints and your BFF surfaces both live in route.ts.
This is the most common trigger in a maturing SaaS.
A provider such as Stripe, Resend, or a Svix-backed sender POSTs to a fixed URL you give them and signs the request with an HMAC over the raw body.
To verify that signature, you have to hash the exact bytes they sent.
The action parses the body into a typed payload before your code ever sees it, and once the bytes are parsed and re-serialized, the signature won’t match.
The route handler hands you the raw bytes directly:
const raw = await request.text();
// verify the HMAC over `raw` before you parse it
You verify first, then parse.
The full verification and deduplication come in the Stripe billing chapter; here the point is only why a webhook forces a handler.
Server Actions are POST-only, so any read surface that has to be a GET needs a route handler: a public /api/posts/[slug], an autocomplete endpoint, a JSON feed, a calendar .ics export.
One wrong turn to avoid: your own pages do not need GET handlers to read data.
A Server Component reads from the database directly, in-process, with no HTTP round-trip and no handler.
The GET handler exists for external readers: a browser hitting a public URL, a partner polling a feed, a CDN caching a response.
The one in-app exception is a Client Component that genuinely can’t be a Server Component and has to fetch its data over HTTP, which you’ll meet in the TanStack Query chapter.
If you’re writing a GET handler for data one of your own Server Components could just read, you don’t need it.
The envelope’s ~1 MB cap and buffered request/response model rule out a whole class of work: direct multipart file uploads, AI responses streamed token by token (the AI SDK’s streamText), Server-Sent Events pushing live progress, and file downloads larger than the cap.
The action buffers the whole body into memory and caps it; this work needs the handler’s direct Request/Response surface and the platform’s streaming runtime.
The mechanics come in the AI unit; for now, a buffered, capped POST can’t stream, so streaming means a handler.
Sometimes the endpoint needs the protocol itself as its contract: a Cache-Control header the CDN obeys, an ETag and a 304 Not Modified for conditional requests, content negotiation on Accept, or a status code the action’s Result shape can’t express.
A Result is a JavaScript value; it has no concept of a 304 or a 307 redirect.
The route handler is the only surface that speaks raw HTTP.
This overlaps with the GET trigger on caching headers and the webhook trigger on statuses, but it stands alone for an otherwise in-app-shaped operation that still needs an HTTP-native response.
Now the other side, the part you’ll lean on most: everything else stays a Server Action.
Every in-app form, every authenticated dashboard mutation, every CRUD operation a React component invokes.
The rule of thumb collapses to one question: is the caller a React component on this same Next.js app?
If yes, write the action.
Only past that envelope does the handler earn its weight.
Walk the five triggers in the order an experienced engineer asks them, not as a flat list.
Caller identity comes first, because it resolves most cases before you weigh anything else.
Action or route handler?
The caller is React on this app and the payload fits the envelope. You get a typed call site, free revalidation, and progressive enhancement. This is the 2026 default; don’t reach further.
The action buffers the whole body into memory and caps it at ~1 MB. Streaming and large bodies need the handler’s direct Request/Response surface and the platform’s streaming runtime. The mechanics come in the AI unit.
A Result is a JavaScript value, so it can’t express a 304 Not Modified, an ETag, or content negotiation on Accept. The protocol is the contract, and only the handler speaks raw HTTP.
Nothing outside React on this app can import an action, and its action ID is an internal bundle artifact, not a public contract. A public REST or BFF surface needs a real, stable HTTP URL. This is the most common trigger in a maturing SaaS.
Signature verification must run over the exact raw bytes the provider sent, and the action’s typed-payload contract parses those bytes away before your code sees them. The handler reads await request.text(), verifies, then parses. Full treatment in the Stripe billing chapter.
Server Actions are POST-only, so any read that has to be a GET needs a handler. But an in-app read still belongs in a Server Component reading the database directly; this leaf is for external readers such as a public URL, a partner polling a feed, or a CDN.
Now practice the boundary yourself.
The deciding axis is caller identity plus protocol need, not the verb (create / read / update) and not the entity.
The mix below is weighted so you can’t pass by always picking “handler.”
Sort each endpoint into the seam it belongs to. The deciding axis is who calls it plus whether the protocol is the contract — not the verb, not the entity.
Drag each item into the bucket it belongs to, then press Check.
Server ActionCalled by a React component on this app
Route handlerCalled over HTTP by something else
Submit the “edit invoice” form from the dashboard
Stripe sends a payment_intent.succeeded event
The iOS app fetches the user’s invoice list
A button on the settings page archives a project
Serve a public /api/changelog.json feed, cached at the CDN
This is orientation, not the full contract: parsing the request with Zod, choosing status codes, and shaping error bodies come in the next lessons.
For now, just learn to recognize the file.
A handler is a file named route.ts.
It can live anywhere in app/ that isn’t already a page segment, but by convention it goes under app/api/....
Here a page route and an API route sit next to each other:
Directoryapp/
Directorydashboard/
page.tsxa UI page, served as HTML
Directoryapi/
Directoryinvoices/
Directory[invoiceId]/
route.ts an API route, served as an HTTP response
You don’t register routes or wire a router.
The framework picks the file by its location in app/ and the function by its name: you export one async function per HTTP method you support (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS), and it dispatches to the matching one.
No central route table, no switch (method).
Here is a single route.ts for one invoice, so it has a dynamic [invoiceId] segment, handling two methods:
The signature. Each handler gets a NextRequest (a thin superset of the Web Request with .nextUrl, .cookies, and geo helpers) and, for a dynamic segment, a context whose params is a Promise in Next.js 16, so await it before use. The RouteContext<'/api/invoices/[invoiceId]'> helper type is globally available (generated by next dev/build, no import) and types params against the route’s segments. Forgetting the await is the single most common Next.js 16 migration mistake.
Return a Response. NextResponse is the Response superset with .json(), cookie, and redirect helpers; a bare Response works too. The POST returns 201 because it created something; choosing status codes comes two lessons on.
A deliberate gap: no Zod parse, no auth check, no error shape yet. This is the skeleton, not the production handler.
1 / 1
The framework also covers two cases for you.
CORS preflight needs no OPTIONS handler; it’s auto-implemented from the methods you export.
A request with an unsupported method gets a 405 Method Not Allowed automatically, with an Allow header listing exactly the methods this file exports.
Hand-write OPTIONS only when your preflight response headers need to diverge.
The instinct to resist is the fat handler: one function with if (request.method === 'POST') branches inside it.
Export GET and POST as separate functions instead.
That’s what the method-name dispatch is for.
One structural rule to know before you trip on it: route.ts and page.tsx cannot share a route segment.
The framework can’t serve both a UI page and an HTTP response at one path without knowing which you meant, so a segment is either a page or an API route.
That’s why the file tree above tucks the handler under app/api/...: keeping API routes on their own branch keeps the conflict from ever arising.
A GET route handler is dynamic and uncached by default: it runs at request time on every request, with no full-route cache in front of it.
To cache a response, you opt in deliberately at that one endpoint, either with the 'use cache' directive or with response headers like Cache-Control: public, s-maxage=60.
That default protects you.
Most route handlers serve authenticated, per-user requests: this user’s invoices, that user’s settings.
Caching those is not just unhelpful, it’s dangerous, because a shared cache could hand one user’s data to another.
So you add caching only at the specific endpoints that serve public, shareable data, and nowhere else.
Cached at the CDN for 60s. Safe only because the changelog is the same for everyone. Never do this with per-user data.
One trap to know in advance: 'use cache' cannot go directly inside a route-handler body.
To cache the work, extract it into a helper function, put 'use cache' on the helper, and call it from your handler.
A route handler is not a new mental model.
It runs the same five seams as a Server Action:
parse → authorize → mutate → revalidate → return
You still safeParse on entry before touching anything, and you still authorize before any database read or write.
A route handler is a public, untrusted-input boundary with the same trust posture as the action, so parse, authorize, and mutate are identical on both sides.
Only the last two seams change shape: revalidate becomes “set cache headers, or call revalidateTag(tag, profile) when the handler mutates” (Next.js 16 requires that second cacheLife argument; 'max' is the safe default), and return a Result becomes “return a Response with a status code and a body.”
Server Action
seam
Route handler
safeParsesame
parse
safeParsesame
authorizesame
authorize
authorizesame
mutatesame
mutate
mutatesame
updateTag(tag)
revalidate
set headers / revalidateTag(tag, profile)
return Result
return
return Response + status
Only revalidate and
return change shape —
everything above is identical.
The five seams in run order. Parse, authorize, and mutate are identical on both sides; only revalidate and return take a different wire format.
Authorization ports with the seam.
The authedAction(role, schema, fn) wrapper that lifts session, role, and parse out of every action body has a twin at the handler boundary: authedRoute(role, schema, handler), same parse-then-authorize order, different return shape, built in the organizations chapter.
You don’t hand-roll a fresh auth check inside every handler; there’s a wrapper for that.
Don’t invent a parallel router.
Use the framework’s surface rather than bolting a second one beside it: route.ts files in the App Router are your API surface.
No Hono, no tRPC, no Express on the side.
Only one scenario flips this, an externally-published, versioned REST API with OpenAPI docs and a shipped client SDK, which most web apps don’t ship in their first year.
The first two pages are the Next.js references worth keeping open when you start writing handlers for real. The rest cover the protocol fundamentals the triggers push you toward: caching headers, conditional requests, and the raw-body discipline a webhook demands.