Next.js Server Actions: A Form Actions Deep Dive
How Next.js Server Actions replace API routes for form submissions — progressive enhancement, useFormState and useFormStatus, validation, revalidation, and avoiding the most common mistakes.
What you'll learn
- ✓How a Server Action replaces a POST handler
- ✓Progressive enhancement: forms that work without JS
- ✓useFormState and useFormStatus for UX
- ✓How revalidatePath and revalidateTag refresh data
Prerequisites
- •Comfortable with the App Router and React hooks
What and Why
A Server Action is a function that runs on the server but is called like a regular function from the client. In the App Router, you can attach one directly to a <form action={...}>, and Next.js wires up the POST, parses the body, runs the function, and triggers any data revalidation you ask for. The end result: form handling without a separate API route, without fetch, and crucially, with progressive enhancement — the form submits even if JavaScript has not loaded.
This matters because forms are the most common interactive surface on the web, and every layer between “user clicks Submit” and “server writes the row” is a place to leak bugs.
Mental Model
A Server Action is a function with 'use server' at the top (or defined in a 'use server' module). When the client imports it, Next.js replaces the body with a stub that POSTs to a hidden endpoint. The arguments are serialized as FormData; the response replaces or revalidates the relevant UI.
There are two flavors. Form actions (<form action={fn}>) accept a FormData argument and work without JS. Imperative actions are called from event handlers like normal functions — useful for buttons that are not forms but still need a server call.
Hands-on Example
Build a “create todo” form. Start with the action:
// app/todos/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { db } from '@/lib/db';
const Schema = z.object({ title: z.string().min(1).max(120) });
export async function createTodo(_prev, formData) {
const parsed = Schema.safeParse({
title: formData.get('title'),
});
if (!parsed.success) {
return { error: 'Title must be 1–120 characters.' };
}
await db.todo.create({ data: parsed.data });
revalidatePath('/todos');
return { error: null };
}
Now the form, using useFormState for error display and useFormStatus for the pending state:
// app/todos/NewTodoForm.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createTodo } from './actions';
function Submit() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving…' : 'Add'}</button>;
}
export default function NewTodoForm() {
const [state, formAction] = useFormState(createTodo, { error: null });
return (
<form action={formAction}>
<input name="title" required />
<Submit />
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
User clicks Submit
|
v
[Browser] serialize <form> -> FormData
|
v (POST to hidden action endpoint, even with JS off)
[Server] run createTodo(prevState, formData)
|-- validate (Zod)
|-- write to DB
|-- revalidatePath('/todos') <- marks cached segment stale
|
v
[Response] new state + RSC payload for stale segments
|
v
[Client] useFormState receives new state
cached UI re-renders with fresh server data With JavaScript disabled, the browser does a full page POST to the same endpoint and re-renders the page from the server response. With JS, only the affected segments stream back. Same code, two experiences.
Common Pitfalls
The first trap is forgetting revalidatePath or revalidateTag. The mutation succeeds, the response arrives, and the list still shows the old data because the cached segment never invalidated. Always revalidate the affected path after a write.
Second, passing non-serializable data. Server Actions accept and return values that can be serialized — primitives, plain objects, FormData, Dates. A class instance or function will throw at runtime.
Third, trusting the client. The action runs on the server, but it is publicly callable. Anyone can POST to that endpoint with any payload. Always validate input and check authorization inside the action, exactly as you would in an API route.
Fourth, using actions for read queries. Actions are for writes (or operations with side effects). For reads, prefer a Server Component that fetches directly — actions add unnecessary indirection and cannot be cached.
Best Practices
Co-locate actions with the components that use them in a single actions.ts file per feature. Always validate with a schema library (Zod, Valibot) and return structured errors that useFormState can render. Use redirect() from next/navigation after a successful create when you want to send the user to the new resource. Combine revalidateTag with tagged fetches when multiple routes depend on the same data — one tag invalidates everything at once. Add <noscript> fallback text only when the form genuinely needs JS for something, since the default already degrades gracefully. Finally, log every action invocation server-side; the abstraction can hide what is really happening.
Wrap-up
Server Actions collapse the form-handling stack into a single function. With useFormState, useFormStatus, and a revalidation call, you get progressive enhancement, optimistic UX, and cache-correct data — all without writing a single API route. The mental shift is small but the surface area you delete is significant.
Related articles
- Next.js Next.js Server Actions Deep Dive
Server Actions explained: what they are under the hood, how to use them for forms and mutations, and the security and UX patterns that matter.
- Next.js Next.js Server Actions Explained
Use Next.js server actions to handle form submissions and mutations without API routes. Covers use server, revalidation, and security considerations.
- Next.js Error Boundaries in the Next.js App Router
How error.tsx, global-error.tsx, and not-found.tsx work in the Next.js App Router — when each one fires, how segments isolate failures, and patterns for recovery and observability.
- Next.js Deploying Next.js on Vercel: A Practical Guide
A hands-on walkthrough for shipping a Next.js app to Vercel — connecting Git, configuring environment variables, understanding preview deployments, and avoiding the usual production gotchas.