React Server Actions Guide — Form Mutations in Next.js
In this tutorial, you'll learn about React Server Actions Guide. We cover key concepts, practical examples, and best practices.
React Server Actions let you call server-side async functions directly from client components, handling form submissions and data mutations without building API routes or managing fetch calls.
What You'll Learn
Build forms and mutation flows using React Server Actions in Next.js — handle form submissions server-side, revalidate data, manage errors, and implement progressive enhancement without manual API routes.
Why It Matters
Before Server Actions, every form submission required: an API route, a fetch call, loading state management, error handling, and cache invalidation. Server Actions eliminate all of this — the server function is called directly from the action prop, with built-in revalidation and progressive enhancement.
Real-World Use
A blog comment form calls a Server Action that validates input, writes to the database, and revalidates the comments cache. If JavaScript is disabled, the form still works via native form submission. Doda Browser's feedback form uses this pattern — the action validates, sanitizes, and stores feedback while the page revalidates automatically.
Basic Server Action
Define an async function with "use server" and pass it to a form's action prop:
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createUser(formData: FormData) {
const name = formData.get("name") as string;
if (!name || name.length < 2) {
return { error: "Name must be at least 2 characters." };
}
// Simulate database insert
await fetch("https://api.example.com/users", {
method: "POST",
body: JSON.stringify({ name }),
headers: { "Content-Type": "application/json" },
});
revalidatePath("/users");
return { success: true };
}
// app/users/page.tsx
export default function CreateUserPage() {
return (
<div style={{ maxWidth: 400, margin: "2rem auto" }}>
<h1>Create User</h1>
<form action={createUser} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<input
name="name"
placeholder="Enter name"
required
style={{ padding: 8, border: "1px solid #ccc", borderRadius: 4 }}
/>
<button type="submit" style={{ padding: "8px 16px", background: "#1976d2", color: "#fff", border: "none", borderRadius: 4 }}>
Submit
</button>
</form>
</div>
);
}
Expected output: When the user submits the form, createUser runs on the server. The form works without JavaScript (native form submission). After success, /users revalidates and shows updated data. Errors return to the client without a page reload.
Server Actions with useActionState
Track pending state and errors client-side using useActionState (formerly useFormState):
"use client";
import { useActionState } from "react";
import { createUser } from "./actions";
const initialState = { error: "", success: false };
export function CreateUserForm() {
const [state, formAction, pending] = useActionState(createUser, initialState);
return (
<form action={formAction} style={{ maxWidth: 400, margin: "2rem auto", display: "flex", flexDirection: "column", gap: 12 }}>
<h1>Create User</h1>
<input
name="name"
placeholder="Enter name"
required
style={{ padding: 8, border: "1px solid #ccc", borderRadius: 4 }}
/>
{state.error && (
<p style={{ color: "#d32f2f", margin: 0 }}>{state.error}</p>
)}
{state.success && (
<p style={{ color: "#2e7d32", margin: 0 }}>User created!</p>
)}
<button
type="submit"
disabled={pending}
style={{
padding: "8px 16px",
background: pending ? "#ccc" : "#1976d2",
color: "#fff",
border: "none",
borderRadius: 4,
cursor: pending ? "not-allowed" : "pointer",
}}
>
{pending ? "Submitting..." : "Submit"}
</button>
</form>
);
}
Expected output: useActionState receives the server action return value as state. The button disables while pending. Errors display inline. The form progressively enhances — works without JS and gains pending/error UX when JS loads.
Server Actions with Revalidation and Redirect
Combine revalidatePath, revalidateTag, and redirect for full mutation flow:
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
export async function updatePost(formData: FormData) {
const id = formData.get("id") as string;
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// Validate
if (!title || title.length < 5) {
return { error: "Title must be at least 5 characters." };
}
// Update database
const res = await fetch(`https://api.example.com/posts/${id}`, {
method: "PUT",
body: JSON.stringify({ title, content }),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
return { error: "Failed to update post." };
}
// Revalidate caches
revalidatePath(`/posts/${id}`);
revalidateTag("posts");
// Redirect to the updated post page
redirect(`/posts/${id}`);
}
Expected output: After a successful update, the specific post page and all tagged "posts" caches are invalidated. The user redirects to the updated page. On failure, the form shows the error and stays on the edit page.
Server Actions with Debounced Search
Use Server Actions for server-side search without form submission:
"use server";
import { revalidatePath } from "next/cache";
export async function searchProducts(formData: FormData) {
const query = formData.get("query") as string;
// Server-side search logic
revalidatePath("/products");
return { query };
}
"use client";
import { useActionState, useEffect, useRef } from "react";
import { searchProducts } from "./actions";
export function ProductSearch() {
const [state, formAction, pending] = useActionState(searchProducts, { query: "" });
const formRef = useRef<HTMLFormElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
return (
<div>
<form ref={formRef} action={formAction} style={{ marginBottom: 16 }}>
<input
name="query"
placeholder="Search products..."
onChange={() => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => formRef.current?.requestSubmit(), 300);
}}
style={{ padding: 8, width: "100%", border: "1px solid #ccc", borderRadius: 4 }}
/>
</form>
{pending && <span style={{ color: "#888" }}>Searching...</span>}
</div>
);
}
Expected output: As the user types, the input debounces for 300ms then submits the server action. The search runs on the server and revalidates the product list. The UI shows a pending indicator while the search executes.
Common Errors
Practice Questions
How does a Server Action work without JavaScript? When JS is disabled, the form submits natively to the server endpoint generated by
"use server". The server processes the FormData, runs the action, and responds with the revalidated page or redirect.What is the difference between
revalidatePathandrevalidateTag?revalidatePathinvalidates a specific URL pattern's cache.revalidateTaginvalidates all fetch calls tagged with a given string (usingnext: { tags: [...] }). Use path for specific pages, tags for related data across pages.What does
useActionStatereturn and how is it different fromuseState? It returns[state, formAction, pending].stateis the return value of the server action.formActionis a wrapped action that feeds state back to the action function.pendingis a boolean indicating if the action is currently executing.
Challenge
Build a comment system with three Server Actions: create, edit, and delete. Each must validate input, handle errors, revalidate the comment list, and show pending states. The form must work without JavaScript. Add a 500ms artificial delay to test loading UX.
Real-World Task
Find an existing form in your Next.js app that uses a client-side fetch to an API route. Replace it with a Server Action. Remove the API route file. Measure the reduction in client JS bundle size and the improvement in form submission speed.
Mini Project: Blog Admin Panel
Build an admin panel with:
- Create post form with Server Action (title, content, tags)
- Edit post page with pre-populated form and update action
- Delete post button with confirmation and server action
- All actions revalidate the post list cache
- Full progressive enhancement — forms work without JS
Learning Path
flowchart LR A[React Server Components] --> B[React Suspense & Streaming SSR] B --> C[React Server Actions Guide] C --> D[React Forms Guide] D --> E[React Query & TanStack Query] style C fill:#4a90d9,color:#fff
Next Steps
- Previous: React Server Components — Next.js App Router Guide, React Forms Guide — Controlled and Uncontrolled Inputs
- Next: React Suspense & Streaming SSR Explained, React Context Performance Patterns
- Related: React Performance Optimization, React State Management — Redux, Zustand, Context API Compared
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Doda Browser's feedback form uses Server Actions to validate, sanitize, and store user feedback server-side while the page revalidates automatically — no API routes, no client-side fetch calls, and it works without JavaScript.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro