All articles
Web DevelopmentJune 30, 2026 6 min read

Server Actions at Scale: Why We Stopped Using Them for Everything

Server Actions in Next.js feel like magic until your app grows up. Here's where they shine, where they hurt, and the rules we now apply before reaching for them in production.

Server Actions were the headline feature that finally made the App Router feel cohesive. A year into shipping them across real products, we've also collected a list of cases where they made things measurably worse. This is the honest version — what we keep, what we replaced, and the rules we now apply before a 'use server' directive goes near a pull request.

The pitch vs. the production reality

The pitch is clean: colocate mutations with the components that trigger them, skip the API route boilerplate, get progressive enhancement for free. For a contact form or a settings toggle, that's exactly what you get.

The production reality is messier. Server Actions are RPC over HTTP, encoded as multipart form posts, routed through the Next.js runtime, and serialized with React's payload format. They're not free, they're not cacheable in the way GET endpoints are, and they have semantics that quietly bite you once you have more than one tab open, more than one user, or more than one consumer of the same mutation.

Here's the short version of what we learned: Server Actions are an excellent mutation primitive and a poor API surface. Treat them accordingly.

Where Server Actions earn their keep

We still reach for them first in three scenarios.

Form submissions tied to a single page. Sign-up forms, profile edits, support tickets. The action lives next to the form, useActionState handles the pending and error states, and progressive enhancement actually works if JavaScript is slow or blocked.

Mutations that revalidate the page they live on. Updating a row in a table, toggling a feature flag, posting a comment. revalidatePath or revalidateTag after the mutation is concise and correct.

Internal tools and admin panels. Lower traffic, trusted users, fewer concurrent mutations. The ergonomics outweigh the overhead.

// app/(admin)/users/[id]/actions.ts
'use server';

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

const Input = z.object({
  userId: z.string().uuid(),
  role: z.enum(['member', 'admin']),
});

export async function updateUserRole(formData: FormData) {
  const session = await requireAdmin();
  const parsed = Input.safeParse({
    userId: formData.get('userId'),
    role: formData.get('role'),
  });

  if (!parsed.success) {
    return { ok: false, error: 'Invalid input' };
  }

  await db.user.update({
    where: { id: parsed.data.userId },
    data: { role: parsed.data.role, updatedBy: session.userId },
  });

  revalidateTag(`user:${parsed.data.userId}`);
  return { ok: true };
}

This is the shape we like: validated input, authorisation at the top, a single mutation, an explicit cache invalidation. Boring, and that's the point.

Where they quietly hurt

The trouble starts when Server Actions get used as a general-purpose API layer.

Sequential calls from the client

React serialises Server Action invocations from the same component tree. If a user triggers three actions in quick succession — say, reordering a list with drag-and-drop — they queue. You can verify this in the network tab: each call waits for the previous one to resolve before the next leaves the browser. For a mutation that takes 300ms, three drags feels like a full second of jank.

For anything throughput-sensitive — bulk updates, real-time-ish interactions, autosave on every keystroke — we now use a route handler with fetch and our own queue. The ergonomics loss is small; the UX win is real.

Cache invalidation as a blunt instrument

revalidatePath looks innocent. In a heavily cached app it can be devastating. We had a content platform where editors were updating one article and inadvertently invalidating the entire /blog/[slug] segment, which then thundered the origin on the next minute of traffic.

The fix is tag discipline: every cached read gets a specific tag, every action invalidates only what it touched, and revalidatePath is reserved for layout-level changes. If you can't name exactly what your action invalidates, the action is too broad.

Error handling is not as automatic as it looks

Server Actions that throw will surface as a generic error to the client unless you catch and return a result object. Throwing is fine for truly exceptional cases — it'll hit your error.tsx boundary — but for validation, auth, and business-rule failures, return structured data.

type ActionResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string; code?: string };

export async function archiveProject(
  id: string,
): Promise<ActionResult<{ archivedAt: string }>> {
  const session = await getSession();
  if (!session) return { ok: false, error: 'Unauthorized', code: 'AUTH' };

  const project = await db.project.findUnique({ where: { id } });
  if (!project) return { ok: false, error: 'Not found', code: 'NOT_FOUND' };
  if (project.ownerId !== session.userId) {
    return { ok: false, error: 'Forbidden', code: 'FORBIDDEN' };
  }

  const updated = await db.project.update({
    where: { id },
    data: { archivedAt: new Date() },
  });

  revalidateTag(`project:${id}`);
  return { ok: true, data: { archivedAt: updated.archivedAt!.toISOString() } };
}

A discriminated union is boring TypeScript, but it forces the caller to handle the failure case and gives you a stable contract for telemetry.

The rules we apply now

After enough postmortems, these are the questions we ask before writing a Server Action.

Is this called from more than one place?

If a mutation is needed by a form, a keyboard shortcut, and a background sync — that's an API. Build a route handler, wrap it in a typed client, and call it from a Server Action if you want progressive enhancement on the form. The action becomes a thin adapter, not the source of truth.

Does this need to be cancellable, retryable, or queueable?

Server Actions don't expose AbortSignal cleanly to the caller and they don't have a built-in retry story. For autosave, file uploads with progress, or anything that benefits from backoff, use fetch and own the lifecycle.

Is the payload large or binary?

Server Actions serialise through React's protocol. For small JSON it's fine. For multi-megabyte file uploads, go direct to object storage with a presigned URL and use the action only to record the resulting metadata.

Does authorisation depend on shape, not just identity?

Server Actions look like local function calls, which tempts developers to skip the authorisation check because "it's right next to the component that already checked". Don't. Every Server Action is a public endpoint. Treat it like one. We lint for an explicit auth call as the first statement in every 'use server' function.

A reasonable architecture

What we land on, for products of meaningful size, is a three-layer split:

  • Data layer. Pure async functions, no 'use server', fully testable, take a session as a parameter rather than reading it from headers.
  • Action layer. Thin 'use server' wrappers that authenticate, validate with Zod, call the data layer, invalidate caches, and return structured results.
  • Route handlers. For anything called from outside React — webhooks, mobile clients, cron jobs, bulk operations — and re-exported by the action layer when a form needs it.

This split keeps the ergonomics of Server Actions where they win and gives you a normal HTTP surface where they don't. It also makes testing tractable: the data layer is unit-testable without any Next.js runtime at all.

What about React 19's useActionState?

It's good. Use it for forms. The isPending flag, the previous-state argument, and the automatic form reset on success are genuinely nice. Just remember it's a form hook, not a general-purpose mutation hook. For non-form mutations we still reach for useTransition plus a manual state machine, because pretending a drag-to-reorder is a form submission has never ended well in code review.

Where we'd start

If you're auditing an existing Next.js codebase, open your app directory and grep for 'use server'. For each result, ask: who calls this, how often, and what does it invalidate? Anything called from more than one place, or invalidating more than one tag, is a candidate to move behind a proper data layer. Everything else — the forms, the toggles, the admin actions — leave alone. Server Actions are still the right answer for those, and they're still the feature that makes the App Router feel finished. Just stop using them as your entire backend.

If you want a second pair of eyes on an App Router migration or a performance review, our team does this for a living — see our web development services for how we work.

#Next.js#React 19#Server Actions#App Router#Performance

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project