Back to journal
Next.js9 min readJune 15, 2026

Server Actions and Forms in Next.js: The Pragmatic Pattern

A practical guide to Next.js Server Actions and forms: progressive enhancement, Zod validation, useActionState, optimistic updates, and in-action auth security.

#Next.js#React#Server Actions#Forms#TypeScript#Web Security
Server Actions and Forms in Next.js: The Pragmatic Pattern

Server Actions changed how we build forms in Next.js, but most teams either over-trust them or work around them. The pragmatic truth sits in the middle: Server Actions are an excellent default for mutations when you treat them as untrusted public endpoints, layer validation and auth inside every action, and pair them with the React primitives built to handle pending and error states. This is the pattern we reach for on client projects, and the trade-offs that come with it.

What a Server Action Actually Is

A Server Action is a function marked with "use server" that runs only on the server but can be called from a client component as if it were local. Under the hood, Next.js registers an endpoint, serializes the arguments, and posts to it. That mental model matters more than the syntax sugar, because it has two consequences people forget:

  • Every action is a publicly reachable POST endpoint. Anyone can call it with any payload, regardless of what your UI allows.
  • Arguments are serialized across the network, so closures over server-side secrets are fine, but the inputs themselves are attacker-controlled.

Once you internalize "this is a public API route wearing a function costume," the rest of the pattern follows naturally.

Start With a Form That Works Without JavaScript

The single biggest reason to use Server Actions over a fetch-to-route-handler approach is progressive enhancement. When you pass an action directly to a form's action prop, the form submits via a native HTML POST if JavaScript has not loaded yet, then upgrades to the client-side transition once hydration completes.

// app/contact/page.tsx
import { submitBrief } from "./actions";

export default function ContactPage() {
  return (
    <form action={submitBrief}>
      <input name="company" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send brief</button>
    </form>
  );
}

This form is functional on a slow phone, on a flaky connection, with a hydration error, or with a content blocker that nukes your bundle. You get that for free only if you keep the action receiving a FormData object and avoid making the submit button depend on client state. The moment you wire submission through onClick and event.preventDefault(), you have thrown progressive enhancement away.

Validate Everything With Zod

Because the action is a public endpoint, the FormData you receive is untrusted. Validate it at the boundary, before any database call, with a schema you can reuse for typing. Zod remains the pragmatic choice in 2026: small, expressive, and it gives you a typed object on success and structured errors on failure.

// app/contact/actions.ts
"use server";

import { z } from "zod";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

const BriefSchema = z.object({
  company: z.string().min(1).max(120),
  email: z.string().email(),
  message: z.string().min(10).max(5000),
});

export type BriefState = {
  ok: boolean;
  errors?: Record<string, string[]>;
  message?: string;
};

export async function submitBrief(
  _prev: BriefState,
  formData: FormData,
): Promise<BriefState> {
  const session = await auth();
  if (!session) {
    return { ok: false, message: "You must be signed in." };
  }

  const parsed = BriefSchema.safeParse({
    company: formData.get("company"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!parsed.success) {
    return { ok: false, errors: parsed.error.flatten().fieldErrors };
  }

  await db.brief.create({
    data: { ...parsed.data, userId: session.user.id },
  });

  return { ok: true, message: "Brief received. We will reply within a day." };
}

Use safeParse, not parse. You want to return field errors to the UI, not throw an exception that surfaces as an opaque 500. Returning a typed state object is also what makes the next piece, useActionState, work cleanly.

Wire State With useActionState

useActionState (the renamed, stabilized successor to useFormState) is the hook that connects an action to a component's render. It gives you the latest returned state, a wrapped action to pass to the form, and a pending boolean.

"use client";

import { useActionState } from "react";
import { submitBrief, type BriefState } from "./actions";

const initial: BriefState = { ok: false };

export function BriefForm() {
  const [state, action, pending] = useActionState(submitBrief, initial);

  return (
    <form action={action}>
      <input name="company" aria-invalid={!!state.errors?.company} />
      {state.errors?.company && <p role="alert">{state.errors.company[0]}</p>}

      <input name="email" type="email" />
      {state.errors?.email && <p role="alert">{state.errors.email[0]}</p>}

      <textarea name="message" />
      {state.errors?.message && <p role="alert">{state.errors.message[0]}</p>}

      <button type="submit" disabled={pending}>
        {pending ? "Sending..." : "Send brief"}
      </button>

      {state.ok && <p role="status">{state.message}</p>}
    </form>
  );
}

Two things make this robust. First, the form still has a server action attached, so progressive enhancement survives. Second, error and success states are driven by the returned object, not by ad hoc client state you have to keep in sync. For a separate submit button in a nested component, reach for useFormStatus instead of threading pending through props.

Optimistic Updates Without Lying to the User

Optimistic UI is worth it for high-frequency, low-stakes mutations: toggling a like, reordering a list, adding a to-do. It is the wrong tool for a payment or an irreversible delete, where a false success is worse than a half-second spinner.

useOptimistic lets you render a provisional state immediately and reconcile when the action resolves.

"use client";

import { useOptimistic } from "react";
import { toggleFavorite } from "./actions";

export function FavoriteButton({ id, favorited }: { id: string; favorited: boolean }) {
  const [optimistic, setOptimistic] = useOptimistic(favorited);

  return (
    <form
      action={async () => {
        setOptimistic(!optimistic);
        await toggleFavorite(id);
      }}
    >
      <button type="submit" aria-pressed={optimistic}>
        {optimistic ? "Saved" : "Save"}
      </button>
    </form>
  );
}

The rule we hold to: the optimistic state must be cheap to roll back visually, and the action must still be the source of truth via revalidatePath or revalidateTag. If the action throws, React discards the optimistic value and the real state returns. Never use optimism to skip the server round trip entirely.

Security: The Part Most Tutorials Skip

A Server Action looks like an internal function, which lulls teams into omitting authorization. Do not. Treat each action as hostile-input territory.

  • Authenticate inside the action. Reading the session in the page component does not protect the action; they are separate request paths. Call auth() (or your equivalent) at the top of every mutating action.
  • Authorize the specific resource. Confirm the signed-in user owns the row they are editing. A valid session is not permission to mutate someone else's record.
  • Validate and bound inputs. Length limits, enum checks, and type coercion via Zod prevent both injection-adjacent abuse and accidental giant payloads.
  • Do not trust hidden fields. An <input type="hidden" name="userId"> can be rewritten by the client. Derive identity from the session, never from the form.
  • Rate limit sensitive actions. Sign-in, password reset, and contact endpoints are abuse magnets. A per-IP or per-user limiter belongs in the action.
  • Avoid leaking internals in errors. Return user-safe messages; log the stack server-side.

Next.js encrypts the closure variables captured by an action and rotates the keys per build, which protects bound server values. That protects your secrets, not your data model. Authorization is still entirely your job.

When to Use a Server Action vs a Route Handler

Server Actions are a default, not a universal answer. Choose deliberately:

  • Use a Server Action when the trigger is a form or button inside your own app, you want progressive enhancement, and the result feeds back into the same React tree.
  • Use a route handler (app/api/.../route.ts) when you need a stable public API for third parties, webhooks (Stripe, GitHub), non-browser clients, file streaming, or fine-grained HTTP control over headers and status codes.
  • Use a route handler for cross-origin callers; Server Actions are same-origin by design and protected against cross-site invocation.

A common pragmatic split on our projects: Server Actions power the internal app surface, route handlers handle inbound integrations. They coexist without conflict.

Error States and Revalidation

Forms fail. Networks drop, validation rejects, a unique constraint trips. Plan the unhappy paths first:

  • Return structured field errors for validation failures so each input can show its own message.
  • Return a single top-level message for action-wide failures (auth, rate limit, server fault).
  • Wrap genuinely unexpected failures so the action never returns an unserializable error; log the real cause.
  • After a successful mutation, call revalidatePath or revalidateTag so the cached server data reflects the change, or redirect to move the user on.

Avoid try/catch around redirect() swallowing its control-flow signal; redirect throws internally by design, so let it propagate. Keep your catch blocks scoped to the database or network call that can actually fail.

Frequently Asked Questions

Do Server Actions work without JavaScript?

Yes, if you attach the action directly to a form's action prop and read inputs from FormData. Next.js renders a real HTML form that submits via native POST before hydration, then upgrades to a client transition. You lose this only when you intercept submission with client event handlers or depend on client state to build the payload.

Is useActionState the same as useFormState?

useActionState is the stabilized, renamed version of the earlier useFormState hook, now living in React rather than react-dom. It adds a third return value, the pending boolean, which previously required useFormStatus. New code should use useActionState; the older name still works for now but is on the deprecation path.

Are Server Actions secure by default?

No. They are public POST endpoints. Next.js encrypts captured closure variables and blocks cross-origin calls, which protects your secrets and prevents CSRF-style invocation. It does not authenticate users or authorize resource access. You must call your auth check, validate inputs with a schema, and confirm ownership inside every mutating action.

When should I avoid optimistic updates?

Avoid optimism for irreversible or high-stakes mutations: payments, account deletion, sending money, publishing. A false "success" that later reverts erodes trust more than a brief spinner. Reserve useOptimistic for cheap, frequent, easily reversible interactions like likes, toggles, and reordering, where instant feedback clearly outweighs the small reconciliation risk.

Should I still use route handlers with Server Actions?

Yes, for different jobs. Route handlers are right for webhooks, third-party APIs, non-browser clients, file streaming, and precise HTTP control. Server Actions are right for in-app forms and mutations that feed back into your React tree with progressive enhancement. Most production apps use both: actions for internal surfaces, handlers for inbound integrations.

Working with CodeAustral

We build web platforms, AI products, and restaurant tech where forms are rarely just forms: they are the money path, the onboarding step, the support channel. If you want a second set of eyes on your Next.js mutation layer, or you are starting fresh and want it done right the first time, send us a short brief at https://codeaustral.com/contact and we will tell you honestly what we would do.

If the note connects to your work

If the project needs a clearer technical read, send a brief.

Send a brief