Server Actions for Mutations
Server Actions for Mutations
LinkedIn Hook
You build a form in Next.js. You reach for
fetch("POST", "/api/...")out of habit. You create an API route. You wire up loading state. You handle errors. You manually revalidate the cache.That was the old way. Server Actions delete half of that code.
With
"use server", your form submits directly to a function on the server — no API route, no client-side fetch, no manual endpoint wiring. The form works even with JavaScript disabled. React 19 gives youuseFormStatusfor pending states,useActionStatefor form-level feedback, anduseOptimisticfor instant UI updates before the server responds.But most developers get the mental model wrong. They treat server actions like RPC calls and forget about progressive enhancement, revalidation, and error boundaries. In interviews, this is where you either sound like someone who has shipped real Next.js apps or someone who only read the docs.
In this lesson, I break down the full server action workflow — from basic form submission to optimistic updates with rollback — with every pattern you need for production and interviews.
Read the full lesson -> [link]
#NextJS #React #ServerActions #JavaScript #InterviewPrep #Frontend #WebDev #100DaysOfCode
What You'll Learn
- What server actions are and how
"use server"works - How to handle form submissions without API routes
- Using
useFormStatusto show pending/loading states during submission - Using
useActionState(React 19) to manage form state and validation feedback - Implementing optimistic updates with
useOptimisticfor instant UI - Revalidating cached data after a mutation completes
- Error handling patterns — try/catch, returning error objects, and error boundaries
The Concept — What Are Server Actions?
Analogy: The Drive-Through Window
Imagine you are at a restaurant. In the old model, you had to:
- Walk up to the front desk (your React component)
- Write your order on a slip of paper (construct a
fetchrequest) - Hand it to a middleman standing at the counter (your
/api/route handler) - The middleman walks it to the kitchen (your database/service layer)
- The middleman walks back with the food and hands it to you
Server Actions are like a drive-through window that opens directly into the kitchen. You hand your order through the window, the chef takes it, cooks it, and hands you the food — no middleman needed. The form talks directly to the server function.
But here is the critical part: if the restaurant's intercom breaks (JavaScript fails to load), you can still shout your order through the window. This is progressive enhancement — server actions work even without JavaScript because the form uses a standard HTML action attribute.
OLD WAY (API Routes):
Form --> fetch() --> /api/route.ts --> DB --> Response --> setState
SERVER ACTIONS:
Form --> action={serverFunction} --> DB --> Revalidate --> Updated UI
No API route. No manual fetch. No client-side response handling.
Server Actions — The Basics
Defining a Server Action
A server action is an async function marked with "use server". It runs exclusively on the server — the function body is never sent to the client.
// app/actions.ts
// This file contains server actions — all exports are server functions
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
// This function runs on the server when the form submits
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
// Direct database access — this code never reaches the browser
await db.todo.create({
data: { title, completed: false },
});
// Tell Next.js to refresh the cached data for this path
revalidatePath("/todos");
}
Using a Server Action in a Form
// app/todos/page.tsx
// This is a Server Component — no "use client" needed
import { createTodo } from "@/app/actions";
import { db } from "@/lib/db";
export default async function TodosPage() {
const todos = await db.todo.findMany();
return (
<main>
<h1>My Todos</h1>
{/* The form submits directly to the server action */}
{/* No JavaScript required — this works as a standard HTML form */}
<form action={createTodo}>
<input type="text" name="title" placeholder="Add a todo..." required />
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</main>
);
}
What Happens Under the Hood
- The user fills in the form and clicks "Add"
- The browser sends a POST request to the current URL with the form data
- Next.js intercepts this request and routes it to the
createTodofunction - The function runs on the server — it has full access to databases, file systems, environment variables
- After
revalidatePath("/todos"), Next.js re-renders the page with fresh data - The updated HTML is sent to the client — the new todo appears in the list
- If JavaScript is disabled, this still works as a full-page form submission
Inline Server Actions
You can also define server actions inline inside Server Components:
// app/feedback/page.tsx
export default function FeedbackPage() {
// Inline server action — defined right where it is used
async function submitFeedback(formData: FormData) {
"use server";
const message = formData.get("message") as string;
await db.feedback.create({ data: { message } });
revalidatePath("/feedback");
}
return (
<form action={submitFeedback}>
<textarea name="message" placeholder="Your feedback..." />
<button type="submit">Send</button>
</form>
);
}
This is convenient for one-off actions that only one component uses. For shared actions, put them in a separate actions.ts file.
useFormStatus — Showing Pending State
When a form is submitting, you want to show a loading indicator and disable the submit button. React provides useFormStatus for exactly this.
Critical rule: useFormStatus must be called from a component that is a child of the <form>. It does not work if called in the same component that renders the <form> tag.
// components/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
// This component MUST be rendered inside a <form>
export function SubmitButton() {
// pending is true while the parent form's action is executing
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save"}
</button>
);
}
// app/todos/page.tsx
import { createTodo } from "@/app/actions";
import { SubmitButton } from "@/components/SubmitButton";
export default async function TodosPage() {
const todos = await db.todo.findMany();
return (
<form action={createTodo}>
<input type="text" name="title" placeholder="Add a todo..." required />
{/* SubmitButton is a CHILD of the form — useFormStatus works */}
<SubmitButton />
</form>
);
}
What useFormStatus Returns
const { pending, data, method, action } = useFormStatus();
// pending: boolean — true while the form action is running
// data: FormData | null — the form data being submitted
// method: string — the HTTP method (usually "post")
// action: function — reference to the server action being called
Common Mistake: Using useFormStatus Outside the Form
"use client";
import { useFormStatus } from "react-dom";
// BUG: This component renders the form AND tries to read status
// useFormStatus reads from the PARENT form — there is no parent here
export function BrokenForm() {
const { pending } = useFormStatus(); // Always returns pending: false
return (
<form action={someAction}>
<input name="title" />
<button disabled={pending}>Save</button> {/* Never disables */}
</form>
);
}
The fix is always the same: extract the submit button (or any element that needs the status) into its own component and render it as a child of the form.
useActionState — Managing Form State and Validation
useActionState (called useFormState in earlier React versions) gives you a way to track the return value of a server action. This is how you show success messages, validation errors, or any other server-side feedback.
// app/actions.ts
"use server";
// The first parameter is the previous state (from useActionState)
// The second parameter is the form data
export async function createAccount(prevState: any, formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
// Server-side validation
if (!email.includes("@")) {
return { error: "Invalid email address", success: false };
}
if (password.length < 8) {
return { error: "Password must be at least 8 characters", success: false };
}
try {
await db.user.create({ data: { email, password: hashPassword(password) } });
return { error: null, success: true };
} catch (e) {
return { error: "Email already exists", success: false };
}
}
// components/SignupForm.tsx
"use client";
import { useActionState } from "react";
import { createAccount } from "@/app/actions";
import { SubmitButton } from "./SubmitButton";
// Initial state — what the form shows before any submission
const initialState = { error: null, success: false };
export function SignupForm() {
// state: the latest return value from the server action
// formAction: a wrapped version of createAccount to pass to <form action>
const [state, formAction] = useActionState(createAccount, initialState);
return (
<form action={formAction}>
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
{/* Show validation error returned from the server */}
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
{/* Show success message */}
{state.success && <p style={{ color: "green" }}>Account created!</p>}
<SubmitButton />
</form>
);
}
How useActionState Works
- You define a server action that accepts
(prevState, formData)and returns a state object useActionStatewraps that action and manages the state lifecycle- When the form submits, the action runs on the server and returns a new state
- The component re-renders with the updated state — showing errors, success, etc.
- The
prevStateparameter lets you access the previous return value (useful for chaining)
useFormState vs useActionState
React 18: useFormState (from "react-dom") — deprecated name
React 19: useActionState (from "react") — current name
They do the same thing. The rename happened because the hook works with
any async action, not just forms. Use useActionState in new code.
useOptimistic — Instant UI Updates
The user clicks "Add Todo." The server action takes 500ms. Without optimistic updates, the user stares at a spinner for half a second. With useOptimistic, the todo appears instantly in the list, and if the server fails, it rolls back.
// components/TodoList.tsx
"use client";
import { useOptimistic } from "react";
import { addTodo } from "@/app/actions";
type Todo = { id: string; title: string; completed: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
// optimisticTodos: the list to render (includes optimistic additions)
// addOptimisticTodo: function to immediately add a todo to the UI
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
// This reducer merges the optimistic update into the current state
(currentTodos: Todo[], newTodoTitle: string) => [
...currentTodos,
{
id: "temp-" + Date.now(), // Temporary ID — replaced after server responds
title: newTodoTitle,
completed: false,
},
]
);
async function handleSubmit(formData: FormData) {
const title = formData.get("title") as string;
// Step 1: Immediately update the UI (optimistic)
addOptimisticTodo(title);
// Step 2: Actually send to the server
// If this fails, React will revert to the real 'todos' prop
await addTodo(formData);
}
return (
<div>
<form action={handleSubmit}>
<input type="text" name="title" placeholder="New todo..." required />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{
// Visual hint: optimistic items are slightly transparent
opacity: todo.id.startsWith("temp-") ? 0.6 : 1,
}}>
{todo.title}
{todo.id.startsWith("temp-") && " (saving...)"}
</li>
))}
</ul>
</div>
);
}
How useOptimistic Works
1. User submits form
2. addOptimisticTodo("Buy milk") runs synchronously
→ optimisticTodos now includes the new item → UI updates INSTANTLY
3. Server action runs asynchronously (addTodo)
4a. SUCCESS: Parent re-renders with fresh data from server
→ useOptimistic sees new 'todos' prop → replaces optimistic state with real data
4b. FAILURE: Server action throws or the parent re-renders without the new item
→ useOptimistic reverts to the real 'todos' prop → optimistic item disappears
The rollback is automatic. You do not need to manually undo the optimistic update on error — React handles this by comparing the optimistic state against the real prop value when the parent re-renders.
Revalidation After Mutations
After a server action modifies data, the cached pages and data are stale. You need to tell Next.js to refresh. There are three approaches:
1. revalidatePath — Refresh a Specific Route
"use server";
import { revalidatePath } from "next/cache";
export async function deleteTodo(todoId: string) {
await db.todo.delete({ where: { id: todoId } });
// Refresh the /todos page — Next.js will re-render it with fresh data
revalidatePath("/todos");
}
2. revalidateTag — Refresh by Cache Tag
"use server";
import { revalidateTag } from "next/cache";
export async function updateProduct(formData: FormData) {
const id = formData.get("id") as string;
const price = parseFloat(formData.get("price") as string);
await db.product.update({ where: { id }, data: { price } });
// Refresh ALL cached fetches tagged with this product
// This is more granular than revalidatePath
revalidateTag(`product-${id}`);
}
// The corresponding fetch that uses this tag:
// fetch(`/api/products/${id}`, { next: { tags: [`product-${id}`] } })
3. redirect — Navigate After Mutation
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const post = await db.post.create({ data: { title, content } });
// Refresh the posts list page
revalidatePath("/posts");
// Redirect to the newly created post
// redirect() must be called OUTSIDE of try/catch (it throws internally)
redirect(`/posts/${post.id}`);
}
Important: redirect works by throwing a special error internally. If you wrap it in a try/catch, the catch block will swallow the redirect. Always call redirect outside of try/catch blocks, or re-throw it explicitly.
Error Handling Patterns
Pattern 1: Return Error Objects (Recommended for Forms)
Instead of throwing errors, return structured error objects. This works seamlessly with useActionState.
// app/actions.ts
"use server";
type ActionResult = {
success: boolean;
error: string | null;
fieldErrors?: Record<string, string>;
};
export async function updateProfile(
prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const name = formData.get("name") as string;
const bio = formData.get("bio") as string;
// Field-level validation
const fieldErrors: Record<string, string> = {};
if (name.length < 2) fieldErrors.name = "Name must be at least 2 characters";
if (bio.length > 500) fieldErrors.bio = "Bio must be under 500 characters";
if (Object.keys(fieldErrors).length > 0) {
return { success: false, error: "Validation failed", fieldErrors };
}
try {
await db.user.update({
where: { id: getCurrentUserId() },
data: { name, bio },
});
revalidatePath("/profile");
return { success: true, error: null };
} catch (e) {
// Log the real error on the server, return a safe message to the client
console.error("Profile update failed:", e);
return { success: false, error: "Something went wrong. Please try again." };
}
}
// components/ProfileForm.tsx
"use client";
import { useActionState } from "react";
import { updateProfile } from "@/app/actions";
const initialState = { success: false, error: null, fieldErrors: {} };
export function ProfileForm({ user }) {
const [state, formAction] = useActionState(updateProfile, initialState);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" defaultValue={user.name} />
{/* Show field-specific error */}
{state.fieldErrors?.name && (
<span style={{ color: "red" }}>{state.fieldErrors.name}</span>
)}
</div>
<div>
<label htmlFor="bio">Bio</label>
<textarea id="bio" name="bio" defaultValue={user.bio} />
{state.fieldErrors?.bio && (
<span style={{ color: "red" }}>{state.fieldErrors.bio}</span>
)}
</div>
{/* Show general error */}
{state.error && !state.fieldErrors && (
<p style={{ color: "red" }}>{state.error}</p>
)}
{state.success && (
<p style={{ color: "green" }}>Profile updated successfully!</p>
)}
<SubmitButton />
</form>
);
}
Pattern 2: Throw Errors for Error Boundaries
If a server action throws an error, the nearest error.tsx boundary catches it. Use this for unexpected/fatal errors.
// app/actions.ts
"use server";
export async function deleteAccount() {
const userId = getCurrentUserId();
if (!userId) {
// This error will be caught by the nearest error.tsx
throw new Error("You must be logged in to delete your account");
}
await db.user.delete({ where: { id: userId } });
redirect("/");
}
// app/settings/error.tsx
"use client";
// This catches errors thrown by server actions within /settings
export default function SettingsError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
When to Use Which Pattern
Return error objects: Form validation, expected failures, user-facing messages.
Works with useActionState. User stays on the form.
Throw errors: Unexpected failures, auth failures, server crashes.
Caught by error.tsx. Shows a full error UI.
redirect(): After successful mutations that should navigate.
Must be called outside try/catch.
Full Example: Complete Mutation Workflow
Here is a complete example that ties together every concept — server action, useActionState, useFormStatus, useOptimistic, revalidation, and error handling.
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
type CommentState = {
success: boolean;
error: string | null;
};
export async function addComment(
prevState: CommentState,
formData: FormData
): Promise<CommentState> {
const postId = formData.get("postId") as string;
const body = formData.get("body") as string;
if (!body || body.trim().length === 0) {
return { success: false, error: "Comment cannot be empty" };
}
if (body.length > 1000) {
return { success: false, error: "Comment must be under 1000 characters" };
}
try {
await db.comment.create({
data: { postId, body: body.trim(), authorId: getCurrentUserId() },
});
revalidatePath(`/posts/${postId}`);
return { success: true, error: null };
} catch (e) {
console.error("Failed to add comment:", e);
return { success: false, error: "Failed to post comment. Please try again." };
}
}
// components/CommentSection.tsx
"use client";
import { useActionState, useOptimistic, useRef } from "react";
import { useFormStatus } from "react-dom";
import { addComment } from "@/app/actions";
type Comment = { id: string; body: string; author: string };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Posting..." : "Post Comment"}
</button>
);
}
export function CommentSection({
postId,
comments,
}: {
postId: string;
comments: Comment[];
}) {
const formRef = useRef<HTMLFormElement>(null);
// Optimistic state: show comment immediately before server confirms
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(current: Comment[], newBody: string) => [
...current,
{ id: "temp-" + Date.now(), body: newBody, author: "You" },
]
);
// Action state: track server response for error display
const [state, formAction] = useActionState(
async (prevState: any, formData: FormData) => {
const body = formData.get("body") as string;
addOptimisticComment(body);
formRef.current?.reset(); // Clear the input immediately
return addComment(prevState, formData);
},
{ success: false, error: null }
);
return (
<div>
<form ref={formRef} action={formAction}>
<input type="hidden" name="postId" value={postId} />
<textarea name="body" placeholder="Write a comment..." required />
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
<SubmitButton />
</form>
<ul>
{optimisticComments.map((comment) => (
<li
key={comment.id}
style={{ opacity: comment.id.startsWith("temp-") ? 0.5 : 1 }}
>
<strong>{comment.author}</strong>: {comment.body}
</li>
))}
</ul>
</div>
);
}
Common Mistakes
Mistake 1: Calling useFormStatus in the Same Component as the Form
useFormStatus reads from the nearest parent <form>. If you call it in the component that renders the <form>, there is no parent form — so pending is always false. Extract the button into a child component.
Mistake 2: Wrapping redirect in Try/Catch
redirect() works by throwing a special Next.js error. If you wrap it in try/catch, the catch block swallows the redirect and it never happens. Call redirect after your try/catch block, or re-throw errors that match the redirect pattern.
Mistake 3: Forgetting to Revalidate After Mutations
Your server action updates the database, but the UI does not change. Why? Because Next.js is still serving the cached version of the page. You must call revalidatePath or revalidateTag to tell Next.js the data changed.
Mistake 4: Using Server Actions for Data Fetching
Server actions are for mutations (create, update, delete). For reading data, use Server Components with async/await or Route Handlers. Server actions send a POST request — using them to fetch data breaks caching, bookmarking, and browser back/forward behavior.
Mistake 5: Not Validating Input on the Server
Client-side validation is a convenience for the user. Server-side validation is a security requirement. A server action receives raw FormData from the network — an attacker can send anything. Always validate and sanitize inputs inside the server action, regardless of what the client validates.
Mistake 6: Exposing Sensitive Error Details to the Client
When a database query fails, the error might contain table names, column names, or connection strings. Never return error.message directly to the client. Log the real error on the server and return a generic message.
Interview Questions
Q1: What are server actions in Next.js, and how do they differ from API routes?
Server actions are async functions marked with
"use server"that run exclusively on the server. They can be passed directly to a form'sactionattribute, so the form submits to the function without needing a separate API route or client-sidefetchcall. The key differences: server actions integrate with React's form handling (progressive enhancement,useActionState,useFormStatus), they automatically work without JavaScript, and they can callrevalidatePath/revalidateTagdirectly. API routes are better for external webhooks, third-party integrations, or when you need a traditional REST/GraphQL endpoint. Server actions are specifically designed for mutations triggered by user interactions in the UI.
Q2: How does useFormStatus work, and what is the most common mistake developers make with it?
useFormStatusis a React DOM hook that returns the submission status of the nearest parent<form>. It provides{ pending, data, method, action }wherependingistruewhile the form's action is executing. The most common mistake is callinguseFormStatusin the same component that renders the<form>tag. Since the hook reads from the parent form, and the form is rendered by the same component, there is no parent — sopendingis alwaysfalse. The fix is to extract the submit button into a separate child component that is rendered inside the<form>.
Q3: Explain the difference between useActionState and useOptimistic. When would you use each?
useActionStatemanages the return value of a server action — it tracks what the server sent back (success, error messages, validation feedback). Use it when you need to display server-side validation errors or success confirmations after a form submission.useOptimisticmanages the UI state optimistically — it lets you show an immediate update before the server responds, then automatically reverts if the server fails. Use it when you want the UI to feel instant (adding a comment, toggling a like). In practice, you often combine both:useOptimisticfor the instant visual update anduseActionStatefor handling error messages if the action fails.
Q4: A form mutation updates the database but the UI still shows old data. What went wrong?
The most likely cause is missing cache revalidation. Next.js aggressively caches rendered pages and fetch results. After a server action mutates the database, you must call
revalidatePath("/the-page")to refresh the page's cached data orrevalidateTag("tag-name")to invalidate specific fetch caches. Without revalidation, Next.js serves the stale cached version even though the database has new data. A secondary cause could be that the page uses client-side state (useState) that is not updated — in that case, the data might be fetched fresh but the component is rendering from stale local state.
Q5: How do you handle errors in server actions? What is the difference between returning an error object and throwing an error?
Returning an error object (e.g.,
return { error: "Validation failed" }) keeps the user on the form and allows you to display inline feedback viauseActionState. This is ideal for expected errors like validation failures. Throwing an error (e.g.,throw new Error("Unauthorized")) propagates to the nearesterror.tsxboundary, which replaces the entire route segment with an error UI. This is appropriate for unexpected or fatal errors like authentication failures or server crashes. The general pattern is: return errors for form validation, throw errors for everything else. Additionally,redirect()must be called outside oftry/catchbecause it throws internally to trigger navigation.
Quick Reference — Cheat Sheet
+------------------------------------+----------------------------------------------+
| Concept | Key Point |
+------------------------------------+----------------------------------------------+
| "use server" | Marks a function as server-only. Never |
| | sent to the client. Runs on POST request. |
+------------------------------------+----------------------------------------------+
| form action={serverAction} | Form submits directly to server function. |
| | No API route needed. Works without JS. |
+------------------------------------+----------------------------------------------+
| useFormStatus() | Returns { pending } for the parent form. |
| | MUST be in a child component of <form>. |
+------------------------------------+----------------------------------------------+
| useActionState(action, initial) | Tracks server action return value. |
| | Use for validation errors and success msgs. |
+------------------------------------+----------------------------------------------+
| useOptimistic(state, reducer) | Instant UI update before server responds. |
| | Reverts automatically on failure. |
+------------------------------------+----------------------------------------------+
| revalidatePath("/path") | Refreshes cached page after mutation. |
| | Without this, UI shows stale data. |
+------------------------------------+----------------------------------------------+
| revalidateTag("tag") | Refreshes all fetches with matching tag. |
| | More granular than revalidatePath. |
+------------------------------------+----------------------------------------------+
| redirect("/path") | Navigates after mutation. Must be called |
| | OUTSIDE try/catch (throws internally). |
+------------------------------------+----------------------------------------------+
| Error handling | Return objects for form errors (expected). |
| | Throw for fatal errors (caught by error.tsx).|
+------------------------------------+----------------------------------------------+
RULE: Server actions are for mutations, not data fetching.
RULE: Always validate inputs inside the server action — client validation is optional.
RULE: Always revalidate after mutations or the UI stays stale.
RULE: useFormStatus must be in a CHILD of <form>, not the same component.
Prev: Lesson 4.3 — Loading & Error States Next: Lesson 5.1 — Dynamic Routes & Catch-All Segments
This is Lesson 4.4 of the Next.js Interview Prep Course — 8 chapters, 33 lessons.