Next.js Server Actions in Production: Forms, Validation, Security

Photo by Jakub Żerdzicki

Photo by Jakub Żerdzicki
Server Actions are the biggest quality-of-life win the App Router ever shipped. Before them, every form on a Next.js site meant the same ritual: an API route, a fetch call, manual loading state, manual error plumbing, and a JSON contract you had to keep in sync by hand. Now a form posts straight to an async function and you get the updated UI back in a single roundtrip.
But I have reviewed enough Next.js codebases — my own portfolio, client sites for Indonesian SMBs, and internal tools at work — to know where teams get burned. The mental shift people miss is this: a Server Action is not a function call. It is a public, unauthenticated POST endpoint that happens to look like a function call. Anyone with curl can invoke it with any arguments they want.
This post is the production playbook I wish I had when Server Actions went stable: the form-validation pipeline that actually holds up, what progressive enhancement genuinely buys you, and the four security footguns that show up in real code review after real code review.
When you mark a function with the use server directive, the bundler replaces it on the client with a reference ID — a hash derived from the source location — and exposes a POST handler on the server that dispatches to it. The Next.js docs are blunt about the consequence: Server Functions are reachable via direct POST requests, not just through your application UI. The TypeScript signature you wrote is a suggestion, not a contract. An attacker who grabs the action ID from your HTML can send a string where you expected a number, or an object where you expected FormData.
Two built-in protections are worth knowing. First, actions only respond to POST, and Next.js compares the Origin header against the Host header, which neutralizes most classic CSRF without tokens. Second, variables closed over by an inline action are encrypted with a per-build key before being sent to the client. Everything else — authentication, authorization, input validation, rate limiting — is your job, inside the action body, every single time.
Here is the shape I now use for every production form. The action does three things in strict order: authorize, validate, mutate. Validation runs through Zod because the incoming FormData is hostile input, no matter what your client-side form enforces.
// app/actions/contact.ts
"use server"
import { z } from "zod"
import { auth } from "@/lib/auth"
import { revalidatePath } from "next/cache"
const ContactSchema = z.object({
name: z.string().min(2).max(120),
email: z.string().email(),
message: z.string().min(10).max(5000),
})
export type ContactState = {
ok: boolean
errors?: Record<string, string[]>
}
export async function submitContact(
_prev: ContactState,
formData: FormData
): Promise<ContactState> {
// 1. AuthZ first — every action, every time
const session = await auth()
if (!session?.user) return { ok: false, errors: { _form: ["Unauthorized"] } }
// 2. Validate the hostile input
const parsed = ContactSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors }
}
// 3. Mutate, then revalidate
await saveMessage(session.user.id, parsed.data)
revalidatePath("/inbox")
return { ok: true }
}On the client side, useActionState wires the action to the form and gives you the pending flag and the returned state object for free. No useState for errors, no try-catch around fetch, no loading spinners glued together by hand. The aria-live region means screen readers announce validation errors too — an accessibility win you get almost for free.
// app/contact/form.tsx
"use client"
import { useActionState } from "react"
import { submitContact } from "@/app/actions/contact"
export function ContactForm() {
const [state, formAction, pending] = useActionState(submitContact, { ok: false })
return (
<form action={formAction}>
<input name="name" required minLength={2} />
<input name="email" type="email" required />
<textarea name="message" required minLength={10} />
<p aria-live="polite">{state.errors?.email?.[0]}</p>
<button disabled={pending}>{pending ? "Sending..." : "Send"}</button>
</form>
)
}The marketing line is that Server Action forms work without JavaScript. That is true, with nuance, and the nuance matters on the networks I build for. On a mid-range Android phone on a 3G connection in Jakarta, your hydration JavaScript can take several seconds to arrive. During that window:
So design the no-JS path deliberately: native HTML validation attributes like required, minLength, and type email give you a first line of defense that works before hydration, and your server-side Zod schema is the real enforcement behind it. Progressive enhancement is not a free checkbox; it is a baseline you choose to keep working.
These are not theoretical. Each one of these comes from a pattern I have flagged in actual code reviews of App Router projects.
Auth checked in the page, not the action
The page is wrapped in middleware or a session check, so the developer assumes the action is protected. It is not. The action is its own endpoint. If the first lines of the action body do not verify the session and the user's permission for this specific mutation, anyone can invoke it directly.
Trusting the TypeScript signature
Declaring an argument as id number does nothing at runtime. The client controls every byte of the payload. Validate types and ranges with Zod or manual checks before the value touches your database — especially IDs, because an unchecked ID is an IDOR vulnerability waiting to happen.
Sensitive data in bind arguments
Closure variables in inline actions are encrypted, but arguments passed via the bind method are NOT — they travel to the client in plain form and come back as attacker-controllable input. Never bind secrets or prices, and re-verify anything bound, like record ownership, inside the action.
Dead actions still exposed
Every exported function in a use server file becomes an endpoint, even if no component imports it anymore. Stale experiments and commented-out features keep their POST routes alive. Audit your actions files like you would audit a public API surface, because that is what they are.
Treat every argument list of every Server Action as hostile, the same way you would treat req.body in an Express handler. The function-call syntax is ergonomic sugar; the security model is still client-server.
A mutation is only half the job — the UI and the cache need to catch up. Next.js gives you three primitives, and picking the right one keeps you from sledgehammering your cache:
| API | What it does | When I reach for it |
|---|---|---|
| revalidatePath | Purges the cache for a route path and re-renders it on next request. | Simple cases: a form on the same page or a known list page that shows the mutated data. |
| revalidateTag | Invalidates every fetch tagged with that cache tag, across all routes that use it. | Shared data surfaced in many places — product data shown on listing, detail, and dashboard pages at once. |
| redirect | Throws a control-flow exception and navigates the user; code after it never runs. | Create-then-view flows. Call revalidatePath before redirect, never after, because after is dead code. |
Server Actions genuinely remove an entire layer of boilerplate — on this portfolio the contact flow lost its API route, its fetch wrapper, and about eighty lines of state plumbing. The single-roundtrip model where mutation and updated UI come back together is the right architecture for form-heavy applications.
But the convenience is exactly what makes them dangerous. The discipline is boring and non-negotiable: authorize first, validate everything, revalidate precisely, and audit your use server files as public endpoints. Do that, and Server Actions are the best mutation story React has ever had.
Grep your codebase for use server and count the exported functions. That number is your public POST endpoint count. If it surprises you, you have an audit to do this week.