Server Actions in Practice: Mutations Without the API Layer

Forms that validate, mutate, and revalidate in a single round trip — how I structure Server Actions in production Next.js apps, and where I still write API routes anyway.

Yousef Romany
Yousef Romanyabout ↗2 min read

For years, every mutation meant the same ceremony: create an API route, wire up a fetch in a handler, manage loading state by hand, then refetch everything to update the UI. The App Router collapsed that entire ritual into one function call. Server Actions are the feature I was most skeptical about and now miss the most whenever I work outside Next.js.

This builds on the App Router conventions I use everywhere — this post zooms into the mutation half of the picture.

Server Actions pipeline: from form submit to fresh UI in one round trip

The whole pipeline is one function

Here is a real action from a client dashboard, trimmed to its skeleton:

ts
'use server';

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

const UpdateOrder = z.object({
  id: z.string().uuid(),
  status: z.enum(['pending', 'shipped', 'delivered']),
});

export async function updateOrder(formData: FormData) {
  const user = await getCurrentUser();
  if (!user) return { error: 'Unauthorized' };

  const parsed = UpdateOrder.safeParse({
    id: formData.get('id'),
    status: formData.get('status'),
  });
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  await db.order.update({
    where: { id: parsed.data.id, userId: user.id },
    data: { status: parsed.data.status },
  });

  revalidatePath('/orders');
  return { ok: true };
}

The form posts directly to it. Validation, authorization, mutation, and cache invalidation happen in one round trip — no api/orders/route.ts, no client-side fetch plumbing, no manual cache busting. The <form> works before hydration even finishes, which means it works on terrible connections too.

Rules I never break

  1. Treat every action as a public endpoint. It literally is one — an HTTP POST under the hood. Validate input with a schema, check authorization inside the action, never trust props that arrived from the client.
  2. Return data, don't throw, for expected failures. A validation miss or "duplicate email" is a response, not an exception. Reserve thrown errors for things that should actually blow up.
  3. Keep actions thin. The action parses, authorizes, delegates to a function in lib/, and revalidates. Business logic lives where it can be tested without React.
  4. One concern per action. updateOrderStatus beats a generic submitOrderForm(mode) every time. Generic actions grow into unreviewable switch statements.

Errors without try/catch spaghetti

Pair actions with useActionState and pending states come for free:

tsx
const [state, formAction, pending] = useActionState(updateOrder, null);

return (
  <form action={formAction}>
    <button disabled={pending}>
      {pending ? 'Saving…' : 'Save'}
    </button>
    {state?.error && <p role="alert">{String(state.error)}</p>}
  </form>
);

That's the entire loading-state story. No isSubmitting effect, no aborted-fetch cleanup.

Optimistic UI that doesn't lie

For small toggles — favorites, checkboxes, archive buttons — waiting on the round trip feels sluggish. useOptimistic paints the expected result immediately and reconciles when the action resolves:

tsx
const [optimisticItems, addOptimistic] = useOptimistic(
  items,
  (state, updated: Item) => state.map((i) => (i.id === updated.id ? updated : i))
);

async function toggle(item: Item) {
  startTransition(async () => {
    addOptimistic({ ...item, done: !item.done });
    await toggleItem(item.id);
  });
}

If the server rejects, the UI snaps back automatically. Users get instant feedback; the database stays the source of truth.

Where I still write API routes

Server Actions aren't a total replacement:

  • Webhooks — Stripe and GitHub don't submit your forms; they need real endpoints with signature verification.
  • GET semantics — anything another service polls benefits from route handlers with proper caching headers.
  • Long-running work — an action that streams video transcoding progress is fighting the model; queue a job instead.
  • Non-browser clients — mobile apps and cron jobs want boring JSON endpoints.

Roughly ninety percent of mutations in my projects are actions. The remaining ten percent keep route handlers employed.

Server Actions didn't remove the backend — they removed the boilerplate between it and your form. That distinction is why they stick.

SHAREXLinkedInWhatsApp
Yousef Romany

Yousef Romany

Full-Stack Developer & AI Agent Engineer based in Luxor, Egypt. I build web applications and AI-powered automation for clients worldwide.