Skip to content
Chapter 46Lesson 1

When to reach past Server Actions

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.

Each case below is one the action’s envelope can’t stretch to cover. None is a matter of taste; each is a hard edge the action can’t cross.

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?

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 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

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.tsx a 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:

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 });
}

Two method-named exports, GET and POST, 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 comes two lessons on.

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. 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.

Caching: route handlers are dynamic by default

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

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.

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, and what you get if you do nothing.

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
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.
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.