Server Actions let you run server-side code directly from your components. They replace the need for API routes in many cases, especially for form submissions and data mutations.
Defining a Server Action
A Server Action is an async function marked with "use server". You can define them inline in Server Components or in a separate file.
In a Separate File
Create app/actions.ts:
"use server";
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;
// Save to database (example)
await db.post.create({
data: { title, content },
});
revalidatePath("/posts");
}Using in a Form
Pass the action directly to a <form>:
import { createPost } from "@/app/actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write your post..." required />
<button type="submit">Create Post</button>
</form>
);
}No onSubmit, no fetch, no API route. The form submits directly to the server function.
Form Handling with useActionState
For showing loading states, errors, and success messages, use the useActionState hook. This requires a Client Component:
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
type State = {
error?: string;
success?: boolean;
};
export function PostForm() {
const [state, formAction, isPending] = useActionState(
async (prevState: State, formData: FormData) => {
try {
await createPost(formData);
return { success: true };
} catch {
return { error: "Failed to create post" };
}
},
{} as State
);
return (
<form action={formAction}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write your post..." required />
{state.error && <p className="text-red-500">{state.error}</p>}
{state.success && <p className="text-green-500">Post created!</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
</form>
);
}Mutations with Arguments
Server Actions can accept arguments beyond FormData using .bind():
"use server";
import { revalidatePath } from "next/cache";
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } });
revalidatePath("/posts");
}
export async function toggleLike(postId: string, formData: FormData) {
const userId = await getCurrentUserId();
await db.like.upsert({
where: { postId_userId: { postId, userId } },
create: { postId, userId },
update: {},
});
revalidatePath(`/posts/${postId}`);
}import { deletePost, toggleLike } from "@/app/actions";
export function PostActions({ postId }: { postId: string }) {
const deleteWithId = deletePost.bind(null, postId);
const likeWithId = toggleLike.bind(null, postId);
return (
<div>
<form action={likeWithId}>
<button type="submit">Like</button>
</form>
<form action={deleteWithId}>
<button type="submit">Delete</button>
</form>
</div>
);
}Validation
Always validate input on the server. Never trust client-side validation alone:
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const PostSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
content: z.string().min(10, "Content must be at least 10 characters"),
});
export async function createPost(formData: FormData) {
const raw = {
title: formData.get("title"),
content: formData.get("content"),
};
const result = PostSchema.safeParse(raw);
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await db.post.create({ data: result.data });
revalidatePath("/posts");
return { success: true };
}Revalidation Strategies
After a mutation, you need to update the cache so users see fresh data:
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updatePost(postId: string, formData: FormData) {
await db.post.update({
where: { id: postId },
data: {
title: formData.get("title") as string,
content: formData.get("content") as string,
},
});
// Option 1: Revalidate a specific path
revalidatePath(`/posts/${postId}`);
// Option 2: Revalidate all paths matching a layout
revalidatePath("/posts", "layout");
// Option 3: Revalidate by cache tag
revalidateTag("posts");
}When fetching data, you can tag your requests so revalidateTag knows what to invalidate:
const posts = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});Redirecting After an Action
Use redirect() to send the user to a new page after a mutation:
"use server";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const post = await db.post.create({
data: {
title: formData.get("title") as string,
content: formData.get("content") as string,
},
});
redirect(`/posts/${post.id}`);
}Note: redirect() throws internally, so do not call it inside a try/catch block.
Error Handling
For graceful error handling, return error state instead of throwing:
"use server";
type ActionResult = {
success: boolean;
error?: string;
};
export async function createPost(formData: FormData): Promise<ActionResult> {
try {
const title = formData.get("title") as string;
if (!title) return { success: false, error: "Title is required" };
await db.post.create({ data: { title } });
return { success: true };
} catch {
return { success: false, error: "Something went wrong. Please try again." };
}
}
Summary
- Server Actions are async functions marked with
"use server". - Pass them directly to form
actionprops for zero-boilerplate mutations. - Use
useActionStatefor loading states and error feedback in Client Components. - Always validate input on the server with a library like Zod.
- Use
revalidatePathorrevalidateTagto refresh cached data after mutations. - Use
redirect()to navigate after successful actions (outside try/catch).