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:
- 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.
- No client-side fetch orchestration. No optimistic update via TanStack Query, no manual loading state —
useFormStatuscovers that in a few lines. - 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.