Skip to content
Shenzhen · The Greater Bay Area · Earth

Server Actions vs API Routes: The Caller Decides, Not the Operation

The two mutation primitives in the App Router are not competing implementations of one contract. One is a wire protocol between your own React tree and your own server; the other is an HTTP endpoint someone else can call. This is the decision rule, the failure modes it prevents, and the code on both sides of the line.

8 min read1,868 words
Next.js ArchitectureSystemsNot yet translated.

Why does the right answer depend on who is calling?

Server Actions and route handlers are not two implementations of one contract; they are two contracts with different callers, and the caller is what makes one of them legal. A Server Action is a POST to the URL of the page that rendered it, carrying a build-generated action ID in the next-action header and arguments in React's Flight format — an internal wire protocol between your React tree and your own server. A route handler is an HTTP endpoint at a path you declare with a payload you define, and it is the only one of the two that a program you do not control can integrate with.

I have watched a team spend six weeks wrapping an existing mutation in a partner-facing endpoint made of Server Actions, on the argument that the action already worked and an API route was the same thing with more code. The Android client shipped, loaded the previous build's action ID on the following deploy, and stopped working. The fix was the route handler they had skipped, plus a compatibility window for app versions already installed on devices they could not update.

Server Actions are for mutations your own UI initiates; an API route is for anything another system has to call, and conflating the two produces an interface nobody can integrate with.

That is not a style preference and it is not about performance. It is about whether the caller is a React component you deployed in the same commit as the server, or a program someone else wrote and will upgrade on their own schedule.

What does a Server Action actually put on the wire?

A function marked 'use server' compiles to an opaque ID generated at build time. When the browser calls it, the request is a POST to the path of the current page — not to a URL derived from the function — with the ID in the next-action header and arguments serialized as a Flight payload. The server resolves that ID against the action manifest for the running deployment, compares Origin to Host (that comparison is the CSRF protection, with experimental.serverActions.allowedOrigins as the escape hatch behind a rewriting reverse proxy), reads the body under a size limit, and executes.

Four properties follow:

  • The action has no URL of its own, so there is no address to document in an API reference and no path to version.
  • The ID is deployment-scoped. A tab left open across a deploy submits a stale ID and receives Failed to find Server Action. This request might be from an older or newer deployment. Anything built on a captured ID inherits that message on every release.
  • Arguments and return values cross as Flight, not JSON. Dates and Maps survive; class instances do not, because React rejects them at the client call site with "Only plain objects, and a few built-ins, can be passed to Server Functions."
  • You do not control the HTTP surface. It is always POST, and the status code is the framework's decision rather than yours — there is no way to answer 201 Created or 409 Conflict.

The body limit is 1 MB by default; on this site I set it to 256kb, because the only action in the codebase is a waitlist form and a limit matching the largest legitimate payload is a boundary you can reason about. That is the point of the primitive: narrow, unaddressable, and not meant to be anything else.

Why can an integration team not call my Server Action?

Because everything an integrator needs is either absent or unstable. There is no URL to publish, no media type to document, no schema, and no version to negotiate. A determined partner can still get something working: a handcrafted POST carrying a scraped action ID is let through by the framework even with no Origin header, because that check is aimed at browsers rather than at scripts, and the miss is logged as a warning rather than rejected. The problem is not that the call is blocked. It is that nothing in the contract was ever offered, so the integration rests on a build artifact whose lifetime is set by your release schedule rather than theirs.

The failure is not loud. The call works in development, works in preview, works until the first rolling deploy, and then presents as an integration bug in someone else's codebase, where the partner cannot read your action manifest, pin a version, or distinguish a changed ID from a down endpoint.

Where do the two primitives actually differ?

DimensionServer ActionRoute handler
Addressable URLNone; POST to the rendering page's pathA path you declare, e.g. /api/v1/waitlist
Invocation contractnext-action header plus Flight-serialized argumentsWhatever you define: JSON, form-encoded, multipart
Stable across deploysNo; the ID is build-generatedYes, if you version the path
Callable by a third partyNot supportedYes
CSRFOrigin-to-Host comparison applied by the frameworkYour responsibility
Body limit1 MB by default, configurable per appPlatform request-body limit; no framework-level cap
HTTP controlAlways POST; status code not yours to chooseMethods, status codes, headers, streaming
AuthenticationThe session cookie of the page that rendered itWhatever you implement: API key, signature, mTLS
Client cache after writerevalidatePath / revalidateTag also invalidate the caller's router cacheNo automatic invalidation for any client
ObservabilityAppears as POST to the page path; the operation is in a headerA named route in access logs and tracing

The revalidation row is the one teams discover last, and it is why this decision is expensive to reverse. A Server Action runs inside a request that already knows which route it rendered, so revalidating a path invalidates both the server cache and the caller's router cache. A route handler has no such context: you call revalidatePath or revalidateTag yourself, and clients that already rendered the page are told nothing until they refetch. Whether that is a bug depends on the rendering strategy that decides whether invalidating a tag does anything at all — revalidating a tag nobody cached in terms of is a no-op wearing a cache-control costume.

What goes wrong when the boundary is blurred?

The list is short. The first four rows are bugs with a fix; the last two harden into interface decisions.

Blurred boundaryWhat breaks
Partner POSTs JSON to the page URL with the action's arguments405 Method Not Allowed with Allow: GET, HEAD; without a next-action header the request is not an action, it is a POST to a page
Partner scrapes and replays the action IDWorks until the next build, then Failed to find Server Action on every call from that client
A route handler is added that forwards to the actionTwo contracts, two error shapes, and only one of them is covered by the tests written for the UI
A mobile app reuses the action's payload shapeThe payload is Flight rather than JSON; Date and Map survive the trip and class instances do not
A retrying queue calls a write that was only ever an actionThe second delivery of a request that already committed creates a second record, because nothing in the action's contract asks for an idempotency key
An internal dashboard is handed the partner endpointThe dashboard now carries an API key and a version negotiation path for a caller that is, in fact, yourself

The last two matter most. An interface decision that hardens into an organisational fact is expensive in a way a bug is not: once a retrying external system depends on a write, that write has a contract, and the contract has to be one the caller can uphold.

What does each side of the boundary look like in code?

An action stays small when it is honest about its caller. It returns errors as values rather than throwing: a throw in production crosses the boundary as a generic message plus a digest, which tells the UI nothing about what to do next.

'use server';

import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const WaitlistInput = z.object({
  email: z.string().email().max(254),
  useCase: z.string().max(280),
});

export type WaitlistResult =
  | { ok: true; id: string }
  | { ok: false; reason: 'invalid' | 'duplicate' };

// Internal RPC. The only caller is <WaitlistForm />, deployed with this server.
export async function joinWaitlist(formData: FormData): Promise<WaitlistResult> {
  const parsed = WaitlistInput.safeParse({
    email: formData.get('email'),
    useCase: formData.get('useCase') ?? '',
  });
  if (!parsed.success) return { ok: false, reason: 'invalid' };

  const existing = await db.waitlist.findUnique({ where: { email: parsed.data.email } });
  if (existing) return { ok: false, reason: 'duplicate' };

  const session = await auth();
  const entry = await db.waitlist.create({
    data: { ...parsed.data, userId: session?.userId ?? null },
  });

  revalidatePath('/lab');
  return { ok: true, id: entry.id };
}

The outward-facing route handler carries what the action cannot: an explicit principal, a versioned path, a status code per outcome, and an operation safe to deliver twice.

// app/api/v1/waitlist/route.ts — a contract another system can implement against.
import { NextResponse, type NextRequest } from 'next/server';
import { z } from 'zod';
import { verifyApiKey } from '@/lib/api-keys';
import { db } from '@/lib/db';

export const runtime = 'nodejs';

// lib/api-keys.ts exports: (header: string | null) => Promise<Tenant | null>

const Body = z.object({
  email: z.string().email().max(254),
  source: z.enum(['partner', 'embed']),
});

export async function POST(request: NextRequest) {
  const tenant = await verifyApiKey(request.headers.get('authorization'));
  if (!tenant) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }

  const parsed = Body.safeParse(await request.json());
  if (!parsed.success) {
    return NextResponse.json(
      { error: 'invalid_body', issues: parsed.error.issues },
      { status: 422 },
    );
  }

  const entry = await db.waitlist.upsert({
    where: { tenantId_email: { tenantId: tenant.id, email: parsed.data.email } },
    create: { ...parsed.data, tenantId: tenant.id },
    update: { source: parsed.data.source },
  });

  return NextResponse.json({ id: entry.id }, { status: 201 });
}

Idempotency is the cost of being addressable. An action runs once per user gesture inside a session; an API route is called by clients that retry on timeout, so the write needs a durable claim before it does any work.

-- Route handlers are called by systems that retry. Server Actions are not.
create table idempotency_key (
  tenant_id    uuid        not null references tenant (id) on delete cascade,
  key          text        not null,
  request_hash bytea       not null,
  status_code  smallint,
  response     jsonb,
  created_at   timestamptz not null default now(),
  primary key (tenant_id, key)
);

-- The insert is the lock. One caller wins, everyone else reads the stored response.
insert into idempotency_key (tenant_id, key, request_hash)
values ($1, $2, $3)
on conflict (tenant_id, key) do nothing
returning key;

And the boundary is testable from outside, which the action never is. This is the check I would put in CI for any partner-facing route handler, because it fails the moment someone moves an endpoint behind a session cookie.

# Proves the endpoint is reachable without a browser, a cookie, or a build ID.
curl -sS -o /tmp/waitlist.json -w '%{http_code}\n' \
  -X POST https://example.com/api/v1/waitlist \
  -H "authorization: Bearer $PARTNER_KEY" \
  -H 'content-type: application/json' \
  -d '{"email":"ops@partner.example","source":"partner"}'

# Expect 401 without the key, 422 on a malformed body, 201 on success.
# There is no equivalent for a Server Action: no endpoint, nothing to point curl at.

When is a route handler the wrong choice?

More often than the current fashion suggests. A route handler for every internal form submit buys you a hand-rolled CSRF check, a hand-rolled auth check on each endpoint, a JSON schema to keep in sync with the UI, and a client cache you now refresh by hand — in exchange for a public, addressable surface that no external caller asked for. For a single-field mutation on a page you already rendered, that is cost with no consumer, and every new endpoint is attack surface plus a version you promised not to break.

Two cases where the route handler is also not the answer. If the payload is large, neither primitive is right: mint a signed upload URL from a small route handler and send the bytes directly to object storage, because a 1 MB action limit or a serverless request cap will find you eventually. If the write must be triggered by a schedule rather than a caller, prefer a queue consumer over an endpoint you expose to the internet and hope nobody finds.

The anti-pattern worth naming explicitly is the forwarding handler: a route handler whose body calls the Server Action. It looks like a two-line bridge and it is two contracts maintained forever, with divergent validation, divergent error codes, and a test suite covering one of them. If a write needs an external contract, move the logic into a module both callers import — and give the route handler the API-key check, status codes and idempotency key it needs on its own.

What should I check before shipping the next mutation?

Write down who calls it, by name: if that list contains anything you did not deploy in the same commit, it is a route handler — versioned at /api/v1, explicitly authenticated, returning real status codes, and requiring an idempotency key from callers that retry. If the list is only your own React tree, keep it as an action, return errors as values instead of throwing, and let the framework's Origin check and body limit do the work they were built for. The cheapest moment to make this call is before the endpoint exists, because the migration cost is not the handler — a handler is an afternoon — but the compatibility window with clients you no longer control, measured in app versions installed on devices rather than in pull requests. On this site the entire server-write surface is one action on a waitlist form behind a 256kb limit while every route handler is a force-static GET serving a machine-readable file, and that shape stays legible at a glance, which is why I intend to keep it.

Keep reading

More in Engineering

Ready to build a system?[ Book a Call ]