Next.js Server Actions Practical Guide — From Form Submission to Optimistic Updates with useActionState·useOptimistic
This article is intended for frontend developers who are already familiar with the basics of Next.js App Router and the concept of React Server Components (RSC). If you find yourself repeatedly creating /api/todos, calling it with fetch, and manually aligning types, Server Actions can solve that frustration. If RSC changed how you read data from the server, Server Actions redefines how you send data from the client to the server.
With the official release of React 19 in December 2024, Server Actions are no longer an experimental feature. useActionState, useFormStatus, and useOptimistic have been officially incorporated into the React core. Note, however, that cache APIs such as revalidatePath and revalidateTag, as well as file-system routing covered in this article, are Next.js-specific features. Some content will not work in Remix or a plain React 19 environment.
After reading this article, you will be able to migrate existing API routes to Server Actions, manage form state with useActionState, and implement optimistic UI with useOptimistic. We cover everything from core mechanics to auth middleware setup and cache invalidation strategies — all in one place with patterns you can use in production right away.
Core Concepts
What Are Server Actions?
Server Actions are a pattern that lets you call asynchronous functions that run on the server from client components, as if they were ordinary functions. They can be declared simply by adding the "use server" directive.
// app/actions/todo.ts
"use server";
import { revalidatePath } from "next/cache";
import { db from "@/lib/db";
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
try {
await db.todo.create({ data: { title } });
} catch {
return { error: "Failed to create todo." };
}
revalidatePath("/todos");
return { success: true };
}This function can be connected directly to a <form action={...}> in a Server Component.
// app/todos/page.tsx
import { createTodo } from "@/actions/todo";
export default function TodoPage() {
return (
<form action={createTodo}>
<input name="title" />
<button type="submit">Add</button>
</form>
);
}How It Works Internally — "Looks Like a Function Call, But It's HTTP"
Key Insight: At build time, Next.js assigns a unique identifier to each
"use server"function and automatically converts it into a POST endpoint. When the function is called from the client, an HTTP POST request containing aNext-Actionheader is automatically generated and sent to the server, and the result is returned as a serialized React tree (text/x-component).
To the developer it looks like a function call, but if you open the Network tab, you'll see a POST request flying out. This abstraction eliminates the repetitive cycle of creating an API route file → calling fetch → declaring types.
| Traditional Approach (API Route) | Server Actions |
|---|---|
Create /api/todos/route.ts file |
Write a function in actions/todo.ts |
fetch('/api/todos', { method: 'POST' }) |
Call createTodo(formData) directly |
| Declare request/response types separately | Automatically inferred from the function signature |
| Manage CSRF tokens separately | Handled by Next.js internally |
The "use server" File Separation Pattern
Declaring "use server" at the top of a file causes all exports in that file to be treated as Server Actions. This approach has become the current industry standard.
// actions/post.ts — entire file is Server Actions
"use server";
export async function createPost(data: PostInput) { /* ... */ }
export async function updatePost(id: string, data: PostInput) { /* ... */ }
export async function deletePost(id: string) { /* ... */ }Progressive Enhancement: The default behavior of HTML forms is guaranteed even when JavaScript is disabled or before hydration is complete. Next.js handles it as a traditional HTML form POST and executes the Server Action.
Constraints to Know Before Looking at Real Examples
Server Actions are powerful, but they are not suited for every situation. Understanding the following constraints beforehand will help you follow the context of the example code.
- POST only: GET requests are not supported, so data fetching is best handled directly in Server Components.
- Cannot be called by external clients: Use Route Handlers for integrations with external systems such as mobile apps or Stripe webhooks.
- Public HTTP endpoint: Any
"use server"function can be called by anyone via a POST request. Authentication, authorization checks, and input validation are mandatory. - Serialization constraints: Function arguments and return values must be serializable types. Plain class instances cannot be used.
Practical Application
Example 1: Form Validation Pattern (Basic)
The most common scenario. A pattern for submitting a contact form, displaying validation errors inline, and showing a success message.
Server side — validation and processing
// actions/contact.ts
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Invalid email format"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
export async function sendContact(prevState: unknown, formData: FormData) {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await sendEmail(result.data);
return { success: true };
}Client side — connecting state with useActionState
// components/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { sendContact } from "@/actions/contact";
export function ContactForm() {
const [state, action, isPending] = useActionState(sendContact, null);
return (
<form action={action}>
<input name="email" type="email" />
{state?.error?.email && (
<p className="text-red-500">{state.error.email}</p>
)}
<textarea name="message" />
{state?.error?.message && (
<p className="text-red-500">{state.error.message}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
{state?.success && <p className="text-green-500">Sent successfully!</p>}
</form>
);
}| Code Point | Role |
|---|---|
useActionState(sendContact, null) |
Provides the action, return value (state), and pending state all at once |
prevState parameter |
Receives the previous action result, enabling accumulation and comparison |
form action={action} |
Guarantees Progressive Enhancement — works even without JS |
disabled={isPending} |
Prevents duplicate submissions |
Example 2: Optimistic Update Pattern (Intermediate)
For UI elements like a like button where immediate feedback is critical, you can update the UI first without waiting for a server response, then automatically roll back if it fails.
// components/LikeButton.tsx
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/actions/like";
export function LikeButton({ postId, initialLiked, likeCount }) {
const [optimisticLike, addOptimisticLike] = useOptimistic(
{ liked: initialLiked, count: likeCount },
(currentState, _optimisticValue) => ({
liked: !currentState.liked,
count: currentState.count + (currentState.liked ? -1 : 1),
})
);
const [, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
addOptimisticLike(null); // Reflect in UI immediately
try {
await toggleLike(postId); // Sync with server
} catch {
// If toggleLike fails, the state is automatically rolled back after startTransition completes.
// In production code, it is recommended to also show an error message (e.g., a toast) to the user.
}
});
};
return (
<button onClick={handleClick}>
{optimisticLike.liked ? "♥" : "♡"} {optimisticLike.count}
</button>
);
}Understanding the
useOptimisticsignature: The updater function's actual signature is(currentState, optimisticValue) => newState. When you calladdOptimisticLike(null),nullis passed as the second argument (_optimisticValue). In this example, the next state can be determined from the current state alone, so that argument is unused. If you were optimistically adding an item to a list, you would pass the actual value likeaddOptimistic(newItem)and use it in the updater.
Example 3: Auth Middleware Layer Pattern (Advanced)
Note: This example uses the
next-safe-actionexternal library. It is recommended to consider adopting it only after you are sufficiently comfortable with the basics of Server Actions. It can be installed withpnpm add next-safe-action.
As a project grows, adding authentication code individually to every Server Action becomes unmanageable. With next-safe-action, you can define a middleware layer once and reuse it everywhere.
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { auth } from "@/lib/auth";
export const authAction = createSafeActionClient().use(async ({ next }) => {
const session = await auth();
if (!session) throw new Error("Unauthorized");
return next({ ctx: { user: session.user } });
});// actions/post.ts
"use server";
import { z } from "zod";
import { authAction } from "@/lib/safe-action";
// .schema() performs Zod parsing before the middleware runs.
// If input validation fails, execution never reaches the auth middleware, reducing unnecessary processing.
export const createPost = authAction
.schema(z.object({
title: z.string().min(1),
content: z.string(),
}))
.action(async ({ parsedInput, ctx }) => {
return db.post.create({
data: { ...parsedInput, authorId: ctx.user.id },
});
});Every action wrapped with authAction automatically goes through a session check. User information is injected into ctx.user in a type-safe manner.
Example 4: Cache Invalidation Strategy (Intermediate)
After mutating data, you can combine revalidatePath and revalidateTag appropriately to keep the UI up to date.
// actions/product.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateProduct(id: string, data: ProductInput) {
await db.product.update({ where: { id }, data });
revalidateTag(`product-${id}`); // Precisely invalidate only that product's data
revalidatePath("/products"); // Refresh the entire list page
}| Function | Scope | Best For |
|---|---|---|
revalidateTag(tag) |
Only caches with that tag | Updating a specific resource |
revalidatePath(path) |
The entire specified path | Refreshing lists or layouts |
redirect(path) |
Page navigation + refresh | Navigating to another page after submission |
Pros and Cons
Advantages
| Item | Description |
|---|---|
| Reduced boilerplate | No need to separately write API route files, fetch calls, or type declarations |
| End-to-end type safety | Types between server functions and the client are automatically inferred without serialization boundaries |
| Automatic CSRF protection | POST-only + built-in Origin/Host header validation for Same-Origin requests |
| Progressive Enhancement | HTML form default behavior is guaranteed even without JS |
| Cache integration | Direct integration with the Next.js cache via revalidatePath / revalidateTag |
| Reduced bundle size | Logic runs on the server, so it is not included in the client bundle |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Security pitfall | "use server" functions are public HTTP endpoints |
Apply Zod validation and auth/authorization checks to every action |
| CSRF protection scope | Origin/Host header validation only applies to Same-Origin requests | allowedOrigins configuration is required for cross-origin scenarios |
| No external client calls | Cannot be used for mobile apps or third-party integrations | Write a separate Route Handler for external integrations |
| POST only | GET requests are not possible | Use direct fetch in Server Components for data reads |
| Serialization constraints | Plain class instances cannot be used as arguments or return values | Design within supported types such as Date, Map, and Set |
| Debugging complexity | The network boundary is abstracted, making error tracing difficult | Apply logging middleware and use the Network tab |
Route Handler vs Server Actions: The rule of thumb in production is to use Server Actions for internal app logic that mutates data, and Route Handlers under
app/api/for endpoints that need to be publicly exposed or require GET (Stripe webhooks, mobile app APIs, etc.).
The Most Common Mistakes in Production
- Exposing a Server Action without authentication — Any
"use server"function can be called by anyone via a POST request. Always include session checks and authorization inside the function, or apply them in bulk through a middleware layer likenext-safe-action. - Skipping input validation — Passing
FormDataor arguments from the client directly to the database is a common mistake. Always add server-side validation with Zod or similar; client-side validation alone is not sufficient. - Trying to fetch data with a Server Action — Since they are POST-only, they are not suited for data reads. For read operations, use direct
fetchor ORM calls within a Server Component.
Closing Thoughts
Server Actions are a core pattern for full-stack Next.js development — expressing data mutation logic as a single function without a separate API layer, while delivering both type safety and Progressive Enhancement. They are not a silver bullet for every situation, but for internal app data mutation flows, they are a far more concise and secure alternative to traditional API routes.
Starting in the order below will let you naturally build an intuition for the full picture:
- Migrate one existing API route to a Server Action — Pick the simplest POST endpoint, move it into an
actions/directory as a"use server"file, and you'll get a feel for the entire flow. Installing Zod withpnpm add zodbeforehand will make writing validation code easier. - Write a form from scratch using
useActionState— Either build a new form based onuseActionStatefrom the ground up, or refactor an existinguseState+fetchform to experience the pending, error, and success flow firsthand. - Introduce
next-safe-actionto apply auth middleware in one shot — After installing it withpnpm add next-safe-action, wrapping auth-required actions with theauthActionclient means you no longer need to repeat session-check code in every function.
Next article: React 19's
usehook and Suspense — even more powerful when used alongside Server Actions — introducing a new pattern for declaratively managing data loading state.
References
- Server Actions and Mutations | Next.js Official Docs — Official API reference for Server Actions
- useActionState | React Official Docs — Detailed explanation of the form state management hook
- useOptimistic | React Official Docs — Optimistic update hook and signature details
- React v19 Release Notes — Full list of features officially stabilized in React 19
- next-safe-action Official Docs — Usage guide for the type-safe middleware library
- How to Think About Security in Next.js | Vercel — Deep dive into CSRF protection scope and
allowedOriginsconfiguration - Server Actions vs Route Handlers | MakerKit — Comparison of use cases for both approaches