The small disciplines that keep Next.js server actions honest once the forms get real.
I've been writing Next.js apps for a while now, and server actions are one of those features that looked like a toy when they landed and then quietly became the way I build forms. They cut out an entire layer of API route plumbing that I used to write on autopilot.
But "less plumbing" doesn't mean "no discipline required." In fact, because server actions blur the line between client and server, it's easier than ever to write something that looks clean in a demo and falls apart the first time real input hits it.
Here's how I try to keep them honest.
One action, one intent
The first rule I've landed on is to keep each action tied to a single user intent. Not a single database call. Not a single table. A single thing the user is trying to do.
Updating a profile is an intent. "Save arbitrary user fields" is a dumping ground. The difference sounds semantic, but it shows up everywhere — in the shape of the form, in the error states, in how you log telemetry, and especially in how your future self reads the code.
When an action drifts beyond one intent, I take it as a signal that the UI probably wants to be split too.
Validate at the boundary, every time
Every server action of mine starts the same way: the incoming form data gets parsed and validated through a Zod schema before anything else happens. No database, no external APIs, no conditional logic — just validation.
This is the same principle as validating request bodies in an Express route. The framework just makes it easier to forget, because the function looks like a regular TypeScript function. Any time I skip the validation step because "I know what's coming in," I'm writing a future bug. I try not to.
Return a narrow, predictable state shape
The other thing I try hard to do is return the same shape every time — an object with an ok flag, an optional human-readable message, and an optional map of field-level errors. Whatever structure you pick, keep it consistent across every action in the project.
Why? Because useActionState is going to wire that shape directly into the UI. Every divergent return type is a chance for a form to silently display the wrong thing, and every little divergence adds up to inconsistency users can actually feel.
Pulling common behavior into wrappers
Once I have more than a handful of actions, I start pulling authorization, rate limiting, and logging into small wrapper functions. The goal isn't cleverness — it's making it impossible to forget a check on a new action. If every action flows through a single withAuth-style wrapper, I don't have to remember.
They're not magic, but they're really nice
Server actions reward the same habits good API code always has. They just hide the seams so well that it's easy to skip the habits. Don't.
The payoff, when you get it right, is a codebase where the form, the validation, and the business logic all live close together without turning into spaghetti. That's worth the small amount of extra discipline.