Skip to content
Thomas Foundry
← JournalTech tip

Why server actions cut your client JS in half

A pattern many teams still haven't reached for. A short explanation with code, and when you don't want it.

·4 min read

The old pattern

Three years ago a typical form submit in a Next.js app looked like this:

// app/components/contact-form.tsx (old)
"use client";
 
export function ContactForm() {
  async function submit(data: FormData) {
    const res = await fetch("/api/contact", {
      method: "POST",
      body: JSON.stringify({
        name: data.get("name"),
        email: data.get("email"),
      }),
      headers: { "Content-Type": "application/json" },
    });
    if (!res.ok) {
      // toast, error state, etc.
    }
  }
  return <form action={submit}>...</form>;
}

And alongside it — in a separate file — an API route:

// app/api/contact/route.ts
import { z } from "zod";
 
const schema = z.object({ name: z.string(), email: z.string().email() });
 
export async function POST(req: Request) {
  const body = await req.json();
  const data = schema.parse(body);
  // ... do work
  return Response.json({ ok: true });
}

Two files. Two zod schemas you have to keep in sync. One fetch call with the familiar footguns: manual headers, JSON.stringify, error handling you often forget. And — not to underestimate — a whole blob of client JavaScript dedicated to serialising and sending a form.

What server actions replace

In Next 14+ you can put this entire block in a single file:

// app/contact-form.tsx
import { z } from "zod";
 
const schema = z.object({ name: z.string(), email: z.string().email() });
 
async function submitContact(formData: FormData) {
  "use server";
  const data = schema.parse({
    name: formData.get("name"),
    email: formData.get("email"),
  });
  // ... do work directly here
  return { ok: true };
}
 
export function ContactForm() {
  return <form action={submitContact}>...</form>;
}

No "use client". No fetch. No API route. One zod schema. The form submit itself happens via a progressively enhanced HTTP POST to the same URL, with or without JavaScript.

What's going on?

Server actions are bundled as a POST endpoint that Next generates for you, with a protocol layer on top for argument serialisation. For the user that means:

  1. The form works without JavaScript. Before the page bundle has hydrated, or in browsers where JS is off, the form just POSTs and redirects back.
  2. No client-side fetch orchestration. No optimistic update via TanStack Query, no manual loading state — useFormStatus covers that in a few lines.
  3. Client-side JS payload drops. For a Lumello-style app with 12 forms per page, the win was roughly 40-55% less client JS. Not because the form code got smaller, but because the entire fetch/state/validation frame dropped out of the client bundle.

When you don't want it

Server actions aren't a magic bullet:

  • Bulk operations with real-time feedback (think: a Kanban board where you drag 30 cards at once) — for those UIs you still want a client-side store + optimistic updates. A server action per drag-end would melt your server.
  • Actions that need state outside the request context (long polling, websockets) — server actions are fundamentally POST requests. For live state you still need a separate websocket layer.
  • Third-party widgets with their own state machines (Mapbox draws, Stripe Elements) — those are already client-only and you lose nothing by letting them fetch on your behalf.

Conclusion

Server actions aren't "the new REST." They're the right default for 80% of form submits and mutations in a modern Next project. The remaining 20% is why you still keep an API layer on the shelf.

For Lumello and Sealr everything inside the portal — booking appointments, updating deals, NDA signatures — is server-action-driven. The difference against a REST API approach isn't just dev speed; it's a measurably smaller client bundle that becomes usable faster on poor networks.