Skip to content
Chapter 46Lesson 1

When to reach past Server Actions

A senior's decision rule for when an in-app mutation should stay a Server Action and when it needs a Next.js route handler instead.

You spent the last few chapters making the Server Action your reflex for every mutation, and that was the right instinct: for an in-app form, the action is the right tool. But you have probably already felt a few places where it doesn’t reach. A Stripe webhook can’t submit your form. A mobile app can’t import your Server Action and call it as a function. A public JSON feed needs a GET, and the action is POST-only. These aren’t bugs in the action; they are the edges of what it can do.

This lesson gives you a checklist to re-run on every “should this be an action or a route.ts?” question for the rest of the course. It has two parts: the five concrete triggers that flip the choice to a route handler, and the rule that keeps you inside the action the other ninety percent of the time. By the end you’ll be able to look at any endpoint and say “action” or “handler” with a one-line justification, you’ll recognize the shape of a route.ts file, and you’ll know the one caching fact you have to carry before you write a single handler.

Before you can know when to step outside the action, you need a sharp picture of what’s inside it. You already know all of this from building actions, so read the next list not as new material but as a map of the action’s edges. Every trigger in this lesson is a case where one of these edges gets crossed.

The Server Action envelope

  • POST-only. The framework wires a POST under the hood. There is no GET and no PUT, just one verb.
  • React-caller-only. It’s invoked as a typed function from a React component on this same app, Client or Server. It is never a free-standing URL a third party hits by contract.
  • 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).

So here is the rule to hold onto: for any in-app mutation that fits this envelope, the Server Action is the 2026 default, and you don’t reach further. The rest of this lesson is about the cases that don’t fit.

Five triggers that flip the choice to a route handler

Section titled “Five triggers that flip the choice to a route handler”

Each of the following is a case where the action’s envelope simply can’t stretch. None of them is a matter of taste or “REST feels cleaner”; each is a hard edge the action cannot cross. There are five, and you’ll be sorting real endpoints into them in a minute, so learn the names.

A mobile app. A Zapier or n8n integration. A partner’s backend. A CLI. A webhook from another one of your own services. None of these 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.

Recall from the envelope that 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 you’ll hit 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.

This is where the action’s contract is the wrong shape. The action parses the body into a typed payload before your code ever sees it, and once the bytes have been 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. Earlier in the course you learned HTTP from the consumer side, calling other people’s endpoints; this is the first time the protocol forces you to author one a specific way. The full verification steps and the deduplication that goes with them are built 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.

There is one wrong turn to avoid here, and it is the one almost everyone makes first. 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 route 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 the rare Client Component that genuinely can’t be a Server Component and has to fetch its data over HTTP, and you’ll meet that case specifically when the course reaches TanStack Query. If you find yourself writing a GET handler for data one of your own Server Components could just read, stop: you don’t need it.

The envelope’s ~1 MB cap and its 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.

Treat this one as a recognition trigger. You don’t need the mechanics now; when you reach the AI unit you’ll stream for real. For today, the takeaway is that 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 simply 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 point to remember is that the protocol is the contract, and the route handler is the only surface that speaks raw HTTP. This trigger overlaps with the GET trigger on caching headers and the webhook trigger on specific statuses, but it stands on its own for the case where an otherwise in-app-shaped operation still needs an HTTP-native response.


That’s all five. Now the other side, which is 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 a single 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.

The walkthrough below is the artifact to keep. Rather than memorizing the five triggers as a flat list, walk them in the order an experienced engineer actually asks them. Caller identity comes first, because it resolves most cases before you have to think about anything else. Click through it now, then return to it whenever the question comes up.

Action or route handler?

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 on purpose, 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 Action Called by a React component on this app
Route handler Called 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
Stream an AI-generated summary token by token
Add a comment from the in-app comment box
Save profile changes from the account form

Now that you know when you’d write one, here is what a handler looks like. Keep this light: it’s orientation, not the full contract. Parsing the request with Zod, choosing status codes, and shaping error bodies all come in the next lessons; right now you just need to recognize the file and its conventions.

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/.... Compare the two kinds of segment side by side, a page route and an API route:

  • Directoryapp/
    • Directorydashboard/
      • page.tsx a UI page, served as HTML
    • Directoryapi/
      • Directoryinvoices/
        • Directory[invoiceId] /
          • route.ts an API route, served as an HTTP response

Inside the file, you don’t register routes or wire a router. The framework decides which file by its location in app/, and which function by its name. You export one async function per HTTP method you support (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS), and the framework dispatches to it. There’s no central route table and no switch (method).

Here is a single route.ts that handles two methods. It’s for a single invoice, so it has a dynamic [invoiceId] segment. Step through it:

export async function GET(
request: NextRequest,
{ params }: RouteContext<'/api/invoices/[invoiceId]'>,
) {
const { invoiceId } = await params;
const invoice = await getInvoice(invoiceId);
return NextResponse.json(invoice);
}
export async function POST(request: NextRequest) {
// parse + authorize covered in the next lessons
const body = await request.json();
const created = await createInvoice(body);
return NextResponse.json(created, { status: 201 });
}

The two method-named exports, GET and POST, sitting side by side in one file. The framework dispatches by name, with no router and no switch (method).

export async function GET(
request: NextRequest,
{ params }: RouteContext<'/api/invoices/[invoiceId]'>,
) {
const { invoiceId } = await params;
const invoice = await getInvoice(invoiceId);
return NextResponse.json(invoice);
}
export async function POST(request: NextRequest) {
// parse + authorize covered in the next lessons
const body = await request.json();
const created = await createInvoice(body);
return NextResponse.json(created, { status: 201 });
}

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.

export async function GET(
request: NextRequest,
{ params }: RouteContext<'/api/invoices/[invoiceId]'>,
) {
const { invoiceId } = await params;
const invoice = await getInvoice(invoiceId);
return NextResponse.json(invoice);
}
export async function POST(request: NextRequest) {
// parse + authorize covered in the next lessons
const body = await request.json();
const created = await createInvoice(body);
return NextResponse.json(created, { status: 201 });
}

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 is the next-but-one lesson.

export async function GET(
request: NextRequest,
{ params }: RouteContext<'/api/invoices/[invoiceId]'>,
) {
const { invoiceId } = await params;
const invoice = await getInvoice(invoiceId);
return NextResponse.json(invoice);
}
export async function POST(request: NextRequest) {
// parse + authorize covered in the next lessons
const body = await request.json();
const created = await createInvoice(body);
return NextResponse.json(created, { status: 201 });
}

A deliberate gap: no Zod parse, no auth check, no error shape yet. Those are the next two lessons. This is the skeleton, not the production handler.

1 / 1

The framework hands you two more things for free. You don’t write an OPTIONS handler for CORS preflight; it’s auto-implemented from the methods you export. And a request with an unsupported method gets a 405 Method Not Allowed automatically, with an Allow header listing exactly the methods this file exports. Let the framework return both. Hand-write OPTIONS only when your preflight response headers genuinely need to diverge.

The instinct to resist here is the fat handler: one exported function with if (request.method === 'POST') branches inside it. Export GET and POST as separate functions side by side instead. That’s what the method-name dispatch is for.

A segment is either a page or an API route, never both

Section titled “A segment is either a page or an API route, never both”

There’s one structural rule worth naming before you trip on it: route.ts and page.tsx cannot live at the same route segment. The framework can’t serve both a UI page and an API response at one path, because it wouldn’t know which you meant. So a segment is either a page or an API route. That’s exactly why the file tree above tucks the handler under app/api/...: keeping API routes on their own branch keeps them off the page tree, and the conflict never comes up.

Caching: route handlers are dynamic by default

Section titled “Caching: route handlers are dynamic by default”

This is the one caching fact you have to carry before writing any handler. Everything else about caching comes much later, but this one shapes your defaults from day one.

A GET route handler runs at request time, every time, by default. It is dynamic and uncached: no automatic full-route cache sits in front of it. If you want a response cached, you opt in deliberately, at that one endpoint, either with the 'use cache' directive or with explicit response headers like Cache-Control: public, s-maxage=60.

Here is why, and it’s the part that matters. Most route handlers serve authenticated, per-user requests: this user’s invoices, that user’s settings. For those, caching is not just unhelpful, it’s dangerous, because a shared cache could hand one user’s data to another. Dynamic-by-default keeps you safe. You add caching only at the specific endpoints that serve public, shareable data, and nowhere else.

export async function GET() {
const invoices = await listInvoices();
return NextResponse.json(invoices);
}

Runs on every request. No headers, no caching: the safe default for anything per-user. This is what you get if you do nothing.

One real trap to flag now so it doesn’t catch you: 'use cache' cannot go directly inside a route-handler body. If you want to cache the work, extract it into a helper function, put 'use cache' on the helper, and call it from your handler. People hit this and stare at the error, so it’s worth knowing in advance.

Here’s the reassuring part. The route handler is not a new mental model to learn; it’s the discipline you already own, with a different return shape. You know the five-seam shape of a Server Action by now:

parse → authorize → mutate → revalidate → return

A route handler runs the exact same five seams, with exactly two substitutions. You still parse on entry with safeParse before touching anything. You still authorize before any database read or write. Those two seams are identical, because a route handler is a public, untrusted-input boundary with the same trust posture as the action. 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, and '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
safeParse same
parse
safeParse same
authorize same
authorize
authorize same
mutate same
mutate
mutate same
updateTag(tag)
revalidate
set headers / revalidateTag(tag, profile)
return Result
return
return Response + status
Only revalidate and return change shape — everything above is identical.
Same five seams; only the last two rows change shape. Parse, authorize, and mutate are identical on both sides, because a route handler is a public, untrusted-input boundary with the same trust posture as the action. Only revalidate and return take a different wire format.

Two threads carry over with that boundary, and both are worth naming now even though you’ll build them later.

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). It has the same parse-then-authorize order and the same trust posture, with a different return shape, and it’s built in the organizations chapter. The point for today is that you don’t hand-roll a fresh auth check inside every handler; there’s a wrapper for that.

Don’t invent a parallel router. This is a principle the whole course holds: 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 running on the side. Exactly one scenario would ever flip this: an externally-published, versioned REST API with autogenerated OpenAPI docs and a shipped client SDK. Even then the senior reach is Hono running inside a single Next.js route handler, not a parallel server. Most SaaS products don’t ship a public API in their first year. It’s named here so you recognize it, but building it is out of scope.

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.