Server Actions
Mark an async function with "use server" and call it from a <form action> or a Client Component. The function runs entirely on the server — you can query your database directly.
Progressive Enhancement
Forms using Server Actions work even with JavaScript disabled, making them inherently accessible and resilient.
Server Actions & Forms
Every app needs to mutate data — create a post, update a profile, delete a comment, submit an order. In the Pages Router this meant writing an API route, fetching it from the client, managing loading and error state, and then refreshing the UI. That's a lot of boilerplate for something as fundamental as saving data.
Server Actions change everything. They let you write a server-side function and call it directly from a form or a Client Component — no API route, no manual fetch, no CORS setup. The function runs on the server, can talk to your database directly, and Next.js handles all the wiring automatically.
1. What Is a Server Action?
A Server Action is an async function marked with the "use server"
directive. That directive tells Next.js: "This function always runs on the server,
no matter where it's called from."
You can define Server Actions in two places:
Option A — Inline in a Server Component
// app/posts/new/page.tsx — Server Component
export default function NewPostPage() {
// Defined inline inside a Server Component
async function createPost(formData: FormData) {
"use server"; // ← Makes this function a Server Action
const title = formData.get("title") as string;
const body = formData.get("body") as string;
await db.post.create({ data: { title, body } });
}
return (
<form action={createPost}>
<input name="title" placeholder="Post title" />
<textarea name="body" placeholder="Post body" />
<button type="submit">Publish</button>
</form>
);
}
Option B — In a Dedicated Actions File
// app/actions.ts
"use server"; // ← Top-level directive — every export in this file is a Server Action
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
await db.post.create({ data: { title, body } });
revalidatePath("/posts");
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath("/posts");
}
The dedicated file approach is cleaner for most projects — actions are reusable across multiple pages, easier to find, and easier to test.
2. How Forms Work with Server Actions
Pass a Server Action to the action prop of a <form>
element. When the form is submitted, Next.js automatically collects all form field
values into a FormData object and passes it to your Server Action:
// app/contact/page.tsx
import { sendContactEmail } from "@/app/actions";
export default function ContactPage() {
return (
<form action={sendContactEmail} className="space-y-4 max-w-md">
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required />
</div>
<button type="submit">Send Message</button>
</form>
);
}
// app/actions.ts
"use server";
export async function sendContactEmail(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
// Send email, save to DB, whatever you need
await sendEmail({ to: "admin@example.com", name, email, message });
}
Progressive Enhancement — Works Without JavaScript
This is one of the most underappreciated features of Server Actions. Because the
form uses a native <form action>, it works as a standard HTML
form submission even with JavaScript disabled. The Server Action runs on the server
either way. You get full progressive enhancement for free.
3. Validation with Zod
Never trust form data without validation. The formData.get() returns
string | File | null — you need to validate it before saving to
your database. Zod is the standard choice:
// app/actions.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
const CreatePostSchema = z.object({
title: z.string().min(3, "Title must be at least 3 characters").max(100),
body: z.string().min(10, "Body must be at least 10 characters"),
slug: z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase with hyphens only"),
});
export async function createPost(formData: FormData) {
// Parse and validate
const result = CreatePostSchema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
slug: formData.get("slug"),
});
if (!result.success) {
// Return validation errors to the client
return {
errors: result.error.flatten().fieldErrors,
};
}
// Safe to use — Zod has validated and typed this
const { title, body, slug } = result.data;
await db.post.create({ data: { title, body, slug } });
revalidatePath("/posts");
return { success: true };
}
4. useActionState — Handling Responses and Errors
Server Actions can return values — but you need a way to receive them in the UI.
React's useActionState hook (previously called useFormState)
is the official solution. It wraps a Server Action and gives you the return value
as state:
// app/posts/new/_components/CreatePostForm.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
// Initial state before the form is submitted
const initialState = { errors: {}, success: false };
export default function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction} className="space-y-4 max-w-md">
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" type="text" />
{state.errors?.title && (
<p className="text-red-500 text-sm">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="slug">Slug</label>
<input id="slug" name="slug" type="text" />
{state.errors?.slug && (
<p className="text-red-500 text-sm">{state.errors.slug[0]}</p>
)}
</div>
<div>
<label htmlFor="body">Body</label>
<textarea id="body" name="body" rows={6} />
{state.errors?.body && (
<p className="text-red-500 text-sm">{state.errors.body[0]}</p>
)}
</div>
{state.success && (
<p className="text-green-600 font-medium">Post created successfully!</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "Publishing..." : "Publish Post"}
</button>
</form>
);
}
useActionState returns three values:
state— the return value of the last Server Action call (orinitialStatebefore first submit)formAction— the wrapped action to pass to the form'sactionpropisPending—truewhile the Server Action is running
The Server Action signature needs one extra parameter when used with
useActionState — the previous state:
// app/actions.ts
"use server";
import { z } from "zod";
type ActionState = {
errors?: { title?: string[]; body?: string[]; slug?: string[] };
success?: boolean;
};
export async function createPost(
prevState: ActionState, // ← Required when used with useActionState
formData: FormData
): Promise<ActionState> {
const result = CreatePostSchema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
slug: formData.get("slug"),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await db.post.create({ data: result.data });
revalidatePath("/posts");
return { success: true };
}
5. Calling Server Actions from Client Components
Server Actions aren't limited to form submissions. You can call them directly from any event handler in a Client Component — click handlers, keyboard shortcuts, drag-and-drop, anything:
// components/LikeButton.tsx
"use client";
import { useState } from "react";
import { toggleLike } from "@/app/actions";
export default function LikeButton({
postId,
initialLikes,
}: {
postId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(false);
const [isPending, setIsPending] = useState(false);
async function handleLike() {
setIsPending(true);
// Optimistic update — update UI immediately
setLikes((prev) => (liked ? prev - 1 : prev + 1));
setLiked((prev) => !prev);
// Then run the Server Action
await toggleLike(postId);
setIsPending(false);
}
return (
<button
onClick={handleLike}
disabled={isPending}
className={liked ? "text-red-500" : "text-gray-400"}
>
♥ {likes}
</button>
);
}
// app/actions.ts
"use server";
export async function toggleLike(postId: string) {
// Toggle like in database
await db.like.upsert({
where: { postId },
create: { postId },
update: {},
});
}
This pattern — optimistic update first, Server Action second — gives users instant feedback while the server catches up in the background.
6. useOptimistic — First-class Optimistic Updates
React 19 ships useOptimistic — a hook specifically designed for
optimistic UI updates with Server Actions. It's cleaner than managing optimistic
state manually:
// components/TodoList.tsx
"use client";
import { useOptimistic } from "react";
import { addTodo } from "@/app/actions";
type Todo = { id: string; text: string; pending?: boolean };
export default function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos: Todo[], newTodo: Todo) => [...currentTodos, newTodo]
);
async function handleSubmit(formData: FormData) {
const text = formData.get("text") as string;
// Add optimistically — immediately visible, marked as pending
addOptimisticTodo({ id: crypto.randomUUID(), text, pending: true });
// Run the real Server Action
await addTodo(text);
// When complete, optimistic state is replaced with real data
}
return (
<div>
<ul>
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className={todo.pending ? "opacity-50" : "opacity-100"}
>
{todo.text} {todo.pending && "(saving...)"}
</li>
))}
</ul>
<form action={handleSubmit} className="mt-4 flex gap-2">
<input name="text" placeholder="New todo..." className="border rounded px-2" />
<button type="submit">Add</button>
</form>
</div>
);
}
useOptimistic takes the real state and a reducer function. When you
call addOptimisticTodo, it immediately applies the reducer to create
the optimistic state. Once the Server Action completes and the parent re-renders
with fresh data, the optimistic state automatically reverts to the real data.
7. Redirecting After a Server Action
A common pattern after a successful mutation is redirecting the user to a new page — for example, redirecting to the new post after creating it:
// app/actions.ts
"use server";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
const slug = formData.get("slug") as string;
const post = await db.post.create({
data: { title, body, slug },
});
// Redirect to the new post — this throws internally, so no return needed
redirect(`/blog/${post.slug}`);
}
Important: redirect() inside a Server Action must
be called outside of a try/catch block — because it works by
throwing a special error internally. If you catch all errors, you'll accidentally
catch the redirect too:
// ❌ redirect() gets swallowed by the catch block
export async function createPost(formData: FormData) {
try {
await db.post.create({ ... });
redirect("/posts"); // ← This throws, but catch swallows it
} catch (error) {
console.error(error); // redirect error gets logged here incorrectly
}
}
// ✅ Correct — redirect outside of try/catch
export async function createPost(formData: FormData) {
try {
await db.post.create({ ... });
} catch (error) {
return { error: "Failed to create post" };
}
redirect("/posts"); // ← Called after try/catch, works correctly
}
8. Passing Extra Data to Server Actions
Sometimes you need to pass data to a Server Action that isn't in the form —
like a record ID for an update or delete operation. Use .bind()
to pre-fill arguments:
// app/posts/[id]/edit/page.tsx
import { updatePost } from "@/app/actions";
export default async function EditPostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id } });
// Bind the post ID as the first argument
const updatePostWithId = updatePost.bind(null, id);
return (
<form action={updatePostWithId}>
<input name="title" defaultValue={post?.title} />
<textarea name="body" defaultValue={post?.body} />
<button type="submit">Update Post</button>
</form>
);
}
// app/actions.ts
"use server";
export async function updatePost(id: string, formData: FormData) {
// id comes from .bind(), formData comes from the form submission
const title = formData.get("title") as string;
const body = formData.get("body") as string;
await db.post.update({
where: { id },
data: { title, body },
});
revalidatePath(`/posts/${id}`);
redirect(`/posts/${id}`);
}
You can also pass extra data via hidden form inputs — a simpler approach that
doesn't require .bind():
<form action={updatePost}>
<input type="hidden" name="id" value={post.id} />
<input name="title" defaultValue={post.title} />
<button type="submit">Update</button>
</form>
9. Security — Server Actions Are Public Endpoints
This is the most important thing to understand about Server Actions: they are HTTP endpoints. Next.js exposes them as POST requests that anyone can call — not just your own forms. This means you must treat them with the same security mindset as API routes.
Always Authenticate
// app/actions.ts
"use server";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export async function deletePost(id: string) {
// Always check authentication first
const session = await auth();
if (!session?.user) {
redirect("/login");
}
// Check authorization — does this user own the post?
const post = await db.post.findUnique({ where: { id } });
if (post?.authorId !== session.user.id) {
throw new Error("Unauthorized");
}
await db.post.delete({ where: { id } });
revalidatePath("/posts");
}
Always Validate Input
Never trust formData values. Validate with Zod before using them —
malicious users can send arbitrary POST requests to your Server Actions with
any data they want.
Never Expose Sensitive Data in Return Values
Return values from Server Actions are sent to the client. Don't return database records with sensitive fields — shape the return value explicitly:
// ❌ Exposes sensitive fields
return await db.user.findUnique({ where: { id } });
// ✅ Only return what the client needs
const user = await db.user.findUnique({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
10. Common Gotchas
-
Forgetting
prevStatewhen usinguseActionState. When a Server Action is used withuseActionState, its first parameter must be the previous state. Forgetting this causes a TypeScript error and runtime mismatch. -
Calling
redirect()inside a try/catch.redirect()throws internally — if you catch all errors, you swallow the redirect. Always callredirect()after your try/catch block. -
Not revalidating after mutations. After saving to the database,
call
revalidatePath()orrevalidateTag()— otherwise the user sees stale cached data after the action completes. - Skipping authentication checks. Server Actions are HTTP endpoints. Malicious users can call them directly. Always verify the session at the top of every Server Action that reads or writes user data.
-
Using Server Actions in the wrong context.
"use server"can only be used in async functions or at the top of a file. You can't mark a regular synchronous function or a class method as a Server Action.
Key Takeaways
- Server Actions are async functions marked with
"use server"— they always run on the server regardless of where they're called from. - Pass a Server Action to
<form action={...}>— Next.js handles theFormDatawiring automatically. - Forms using Server Actions work without JavaScript — full progressive enhancement out of the box.
- Always validate with Zod before saving data —
formDatais user input and can't be trusted. - Use
useActionStateto receive validation errors and success responses back in the UI. - Call Server Actions directly from Client Component event handlers for non-form interactions.
- Use
useOptimisticfor instant UI feedback before the server responds. - Call
redirect()outside of try/catch blocks inside Server Actions. - Server Actions are public endpoints — always authenticate and validate inside every action.
That wraps up Module 2 — React Server Components & Data Fetching! You now have a complete picture of how data flows through a Next.js app — from fetching and caching to streaming and mutating. In Module 3 we switch gears to styling — CSS Modules, Tailwind CSS, fonts, and dark mode.