Next.js Server Actions: Complete Guide with Form Handling & Validation - By Sourav Mishra (@souravvmishra)
Learn Next.js Server Actions from basics to advanced patterns. Includes form handling, Zod validation, optimistic updates, useActionState, and error handling.
Server Actions are one of the most significant additions to Next.js. They let you run server-side code directly from your components without creating API routes.
In this guide, I'll show you everything you need to know about Server Actions - from the basics to advanced patterns.
New to Server Actions? If you're wondering why you should use them over API routes or React Query, read Why Server Actions? Answering the Most Common Questions first.
If you're working with TypeScript in your Server Actions (which you should be), check out my guide on TypeScript Generics for writing type-safe, reusable code.
What Are Server Functions?
A Server Function is an asynchronous function that runs on the server. When called from the client, Next.js automatically handles the network request - which is why they must be async.
In a mutation context, these are called Server Actions. By convention, a Server Action is an async function used with React's startTransition. This happens automatically when the function is:
- Passed to a
<form>using theactionprop - Passed to a
<button>using theformActionprop
When an action is invoked, Next.js can return both the updated UI and new data in a single server roundtrip. Behind the scenes, actions use the POST method.
Creating Server Functions
Define a Server Function using the "use server" directive. You can place it at the top of an async function, or at the top of a separate file to mark all exports as Server Functions:
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title");
const content = formData.get("content");
// Validate, save to database, revalidate cache...
}
export async function deletePost(formData: FormData) {
const id = formData.get("id");
// Delete from database, revalidate cache...
}
In Server Components
Server Functions can be inlined in Server Components:
export default function Page() {
async function createPost(formData: FormData) {
"use server";
// This runs on the server
}
return <form action={createPost}>{/* ... */}</form>;
}
Progressive Enhancement: Forms that call Server Actions work even if JavaScript hasn't loaded yet or is disabled.
In Client Components
You can't define Server Functions inside Client Components, but you can import them from a "use server" file:
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
// ...
}
// components/create-button.tsx
"use client";
import { createPost } from "@/app/actions";
export function CreateButton() {
return <button formAction={createPost}>Create</button>;
}
Invoking Server Functions
There are two main ways to invoke a Server Function:
- Forms - In both Server and Client Components
- Event Handlers - In Client Components (onClick, etc.)
Using Forms
React extends the HTML <form> element to accept Server Functions via the action prop. The function receives the FormData automatically:
import { createPost } from "@/app/actions";
export function PostForm() {
return (
<form action={createPost}>
<input type="text" name="title" placeholder="Title" />
<textarea name="content" placeholder="Content" />
<button type="submit">Create Post</button>
</form>
);
}
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// Save to database...
}
Using Event Handlers
For more control, invoke Server Functions directly in event handlers:
"use client";
import { incrementLike } from "./actions";
import { useState } from "react";
export function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return (
<button
onClick={async () => {
const updatedLikes = await incrementLike();
setLikes(updatedLikes);
}}
>
❤️ {likes}
</button>
);
}
Showing Pending States
Use React's useActionState hook to show loading indicators while the action executes:
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
export function CreateButton() {
const [state, action, pending] = useActionState(createPost, null);
return (
<button onClick={action} disabled={pending}>
{pending ? "Creating..." : "Create Post"}
</button>
);
}
For forms, you can also use useFormStatus:
"use client";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Submitting..." : "Submit"}
</button>
);
}
Want to add smooth animations to your pending states? Check out my article on building spring physics animations for inspiration on making UI feel more alive.
Revalidating Data
After a mutation, you'll often want to refresh the cached data. Use revalidatePath or revalidateTag:
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
await db.posts.create({ data: { title } });
// Refresh the posts page
revalidatePath("/posts");
}
For more granular control with tagged data:
"use server";
import { revalidateTag } from "next/cache";
export async function updatePost(id: string, formData: FormData) {
await db.posts.update({ where: { id }, data: { ... } });
// Revalidate all data tagged with "posts"
revalidateTag("posts");
}
Redirecting After Actions
Use redirect to navigate users after a mutation:
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const post = await db.posts.create({ ... });
revalidatePath("/posts");
redirect(`/posts/${post.id}`);
}
Important:
redirectthrows an exception internally, so any code after it won't execute. Always callrevalidatePathorrevalidateTagbeforeredirect.
Working with Cookies
Server Actions can get, set, and delete cookies:
"use server";
import { cookies } from "next/headers";
export async function setThemePreference(theme: string) {
const cookieStore = await cookies();
// Set cookie
cookieStore.set("theme", theme);
}
export async function getThemePreference() {
const cookieStore = await cookies();
return cookieStore.get("theme")?.value;
}
When you modify cookies in a Server Action, Next.js automatically re-renders the page to reflect the new value.
Using with useEffect
You can invoke Server Actions when a component mounts or when dependencies change:
"use client";
import { incrementViews } from "./actions";
import { useState, useEffect, useTransition } from "react";
export function ViewCounter({ initialViews }: { initialViews: number }) {
const [views, setViews] = useState(initialViews);
const [isPending, startTransition] = useTransition();
useEffect(() => {
startTransition(async () => {
const updatedViews = await incrementViews();
setViews(updatedViews);
});
}, []);
return <p>Views: {views}</p>;
}
This is great for:
- Tracking page views
- Infinite scrolling
- Keyboard shortcuts
- Any mutation triggered by global events
Error Handling Best Practices
Always validate input and handle errors gracefully:
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const PostSchema = z.object({
title: z.string().min(1, "Title is required").max(100),
content: z.string().min(10, "Content must be at least 10 characters"),
});
export async function createPost(formData: FormData) {
const rawData = {
title: formData.get("title"),
content: formData.get("content"),
};
const result = PostSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
try {
await db.posts.create({ data: result.data });
revalidatePath("/posts");
return { success: true };
} catch (error) {
return {
success: false,
errors: { _form: ["Something went wrong"] },
};
}
}
For a deep dive into Zod and TypeScript validation patterns, see TypeScript Generics where I cover type-safe API responses.
Key Takeaways
- Use
"use server"to mark functions that run on the server - Forms work without JS - Progressive enhancement is built-in
- Handle loading states with
useActionStateoruseFormStatus - Always validate input - Never trust client data
- Revalidate after mutations - Use
revalidatePathorrevalidateTag - Redirect after create/update - But revalidate before redirecting
Server Actions dramatically simplify data mutations in Next.js. No more /api/ routes for every form submission. They integrate seamlessly with React's concurrent features and Next.js's caching system.
If you're building forms with shadcn/ui components, check out my post on why I recommend design libraries for modern development workflows.
Frequently Asked Questions
Q: What can Server Actions return?
Server Actions can return any serializable data, including objects, arrays, and strings. They cannot return functions or complex classes.
Q: Are Server Actions secure?
Yes, but you must treat them like public API endpoints. Always validate inputs (using Zod) and check authentication/authorization within the action itself.
Q: Can I use Server Actions in Client Components?
Yes. You can import Server Actions into Client Components or pass them as props. They are the recommended way to handle mutations from the client.
Q: How do Server Actions handle errors?
You should return a structured error object (e.g., { error: "message" }) or define a strict return type. Uncaught errors will be generic to prevent leaking sensitive details.