Next.js Server Actions: Complete Guide with Examples
Server Actions are one of those features that completely changed how I think about form handling in Next.js. Instead of creating API routes and managing fetch calls, you can write server-side functions that work seamlessly with your forms. The best part? They work even when JavaScript is disabled—progressive enhancement built right in.
When I first started using Next.js, handling forms meant creating API routes, writing fetch calls, managing loading states, and dealing with error handling. It worked, but it felt like a lot of boilerplate for something that should be simple. Then Server Actions came along, and everything clicked.
Server Actions let you write server-side functions that you can call directly from your components. They handle form submissions, data mutations, and any server-side logic you need. No API routes, no fetch calls, no manual state management. Just write a function, mark it with 'use server', and use it in your form's action prop.
In this guide, I'll walk you through everything you need to know about Server Actions. We'll cover the basics, form validation with Zod, handling loading states, revalidating data, working with cookies, and progressive enhancement. All with real examples from a working application.
What Are Server Actions?
Server Actions are async functions that run on the server. They're marked with the 'use server' directive and can be called directly from client or server components. When used with forms, they provide progressive enhancement—your forms work even if JavaScript fails to load.
Here's the simplest example—a Server Action that creates a post:
"use server";
import { createPost } from "./lib/posts-store";
export async function createPostAction(title) {
const post = createPost(title);
return post;
}That's it. No API route, no fetch call, no complex setup. Just a function that runs on the server. You can call it from anywhere in your app.
Basic Form with Server Action
The simplest way to use a Server Action is with a form. Here's a complete example:
"use server";
import { revalidatePath } from "next/cache";
import { createPost } from "./lib/posts-store";
export async function createPostAction(prevState, formData) {
const title = formData.get("title");
if (!title || title.trim().length === 0) {
return {
message: "Title is required",
fieldErrors: { title: "Title cannot be empty" },
};
}
const post = createPost(title);
revalidatePath("/");
return {
message: "Post created successfully",
fieldErrors: { title: "" },
};
}Notice the function signature: it takes `prevState` and `formData`. The `prevState` is useful when using `useActionState` (we'll cover that next), and `formData` contains all the form fields.
Now, here's how you use it in a form:
"use client";
import { useActionState } from "react";
import { createPostAction } from "./posts-actions";
import SubmitButton from "./submit-button";
const initialState = {
message: "",
fieldErrors: { title: "" },
};
export default function CreatePostForm() {
const [state, formAction, pending] = useActionState(
createPostAction,
initialState
);
return (
<form action={formAction}>
<input
name="title"
required
disabled={pending}
/>
{state.fieldErrors.title && (
<p role="alert" className="text-red-500">
{state.fieldErrors.title}
</p>
)}
<SubmitButton />
<p aria-live="polite">{state.message}</p>
</form>
);
}The `useActionState` hook gives you the current state, the form action, and a `pending` flag. You can disable inputs while the form is submitting, show error messages, and display success messages—all handled automatically.
Form Validation with Zod
Validation is crucial for any form. Here's how to use Zod for server-side validation:
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { createPost } from "./lib/posts-store";
const createPostSchema = z.object({
title: z
.string()
.trim()
.min(1, "Title is required")
.max(80, "Title is too long"),
});
export async function createPostAction(prevState, formData) {
const raw = Object.fromEntries(formData);
const result = createPostSchema.safeParse(raw);
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
return {
message: "Please fix the errors and try again.",
fieldErrors: { title: fieldErrors.title?.[0] || "" },
};
}
const post = createPost(result.data.title);
revalidatePath("/");
return {
message: "Post added.",
fieldErrors: { title: "" },
};
}Zod's `safeParse` returns an object with a `success` boolean. If validation fails, we extract the field errors and return them. If it succeeds, we process the data and return a success message. The form component automatically picks up these errors and displays them.
Loading States with useFormStatus
Showing loading states during form submission improves user experience. The `useFormStatus` hook makes this easy:
"use client";
import { useFormStatus } from "react-dom";
export default function SubmitButton() {
const status = useFormStatus();
return (
<button type="submit" disabled={status.pending}>
{status.pending ? "Adding..." : "Add post"}
</button>
);
}The `useFormStatus` hook must be used inside a component that's a child of a form with a Server Action. It provides a `pending` state that's true while the action is running. Perfect for disabling buttons and showing loading text.
Working with Cookies
Server Actions can read and write cookies. Here's an example that stores the last created post title:
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { z } from "zod";
import { createPost } from "./lib/posts-store";
const createPostSchema = z.object({
title: z
.string()
.trim()
.min(1, "Title is required")
.max(80, "Title is too long"),
});
export async function createPostAction(prevState, formData) {
const cookieStore = await cookies();
const raw = Object.fromEntries(formData);
const result = createPostSchema.safeParse(raw);
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
return {
message: "Please fix the errors and try again.",
fieldErrors: { title: fieldErrors.title?.[0] || "" },
};
}
const post = createPost(result.data.title);
revalidatePath("/");
// Store the last created title in a cookie
cookieStore.set("lastCreatedTitle", post.title);
return {
message: "Post added.",
fieldErrors: { title: "" },
};
}You can read cookies in server components and display them:
import { cookies } from "next/headers";
import CreatePostForm from "./create-post-form";
export default async function Page() {
const cookieStore = await cookies();
const lastCreatedTitle = cookieStore.get("lastCreatedTitle")?.value || "";
return (
<main>
<CreatePostForm />
{lastCreatedTitle && (
<p>Last created: {lastCreatedTitle}</p>
)}
</main>
);
}Updating and Deleting with Server Actions
Server Actions work great for updates and deletes too. Here's how to handle both:
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { updatePostTitle, deletePost } from "./lib/posts-store";
const createPostSchema = z.object({
title: z
.string()
.trim()
.min(1, "Title is required")
.max(80, "Title is too long"),
});
export async function updatePostTitleAction(postId, formData) {
const raw = Object.fromEntries(formData);
const result = createPostSchema.safeParse(raw);
if (!result.success) return;
updatePostTitle(postId, result.data.title);
revalidatePath("/");
}
export async function deletePostAction(postId) {
deletePost(postId);
revalidatePath("/");
}Notice how we use `.bind()` to pass the `postId` to the action. Here's how you'd use these actions in a form:
import { updatePostTitleAction, deletePostAction } from "./posts-actions";
export default async function Page() {
const posts = getPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<form action={updatePostTitleAction.bind(null, post.id)}>
<input
name="title"
defaultValue={post.title}
required
/>
<button type="submit">Save</button>
<button
type="submit"
formAction={deletePostAction.bind(null, post.id)}
>
Delete
</button>
</form>
</li>
))}
</ul>
);
}The `formAction` prop lets you have multiple submit buttons in the same form, each calling a different action. Perfect for edit and delete operations.
Inline Server Actions
You can define Server Actions directly in server components. This is useful for simple actions that don't need to be reused:
import { revalidatePath } from "next/cache";
import Form from "next/form";
import { createPost } from "./lib/posts-store";
export default async function Page() {
async function createPostFromForm(formData) {
"use server";
await new Promise((resolve) => setTimeout(resolve, 800));
const title = formData.get("title");
createPost(title);
revalidatePath("/");
}
return (
<main>
<Form action={createPostFromForm}>
<input name="title" required />
<button type="submit">Add Post</button>
</Form>
</main>
);
}Notice we're using Next.js's `Form` component instead of the regular HTML `form`. This ensures the form works correctly with Server Actions. You can also use regular forms, but `Form` provides better integration.
Redirecting After Actions
Sometimes you want to redirect after a successful action. Here's how:
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { createPost } from "./lib/posts-store";
const createPostSchema = z.object({
title: z
.string()
.trim()
.min(1, "Title is required")
.max(80, "Title is too long"),
});
export async function createPostAction(prevState, formData) {
const raw = Object.fromEntries(formData);
const result = createPostSchema.safeParse(raw);
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
return {
message: "Please fix the errors and try again.",
fieldErrors: { title: fieldErrors.title?.[0] || "" },
};
}
const post = createPost(result.data.title);
revalidatePath("/");
// Redirect to the post page
redirect(`/posts/${post.id}`);
}When you call `redirect()`, the function doesn't return. The redirect happens immediately, so any code after it won't run. This is useful for actions that should always redirect, like creating a new resource.
Progressive Enhancement
One of the best things about Server Actions is progressive enhancement. Your forms work even if JavaScript is disabled. The form submits normally, the Server Action runs, and the page updates. If JavaScript is enabled, you get the enhanced experience with loading states and instant feedback.
This means you don't need to worry about users with slow connections or disabled JavaScript. Your forms will always work.
Complete Example: Posts Management
Let's put it all together with a complete example. Here's the data store:
// lib/posts-store.js
let posts = [
{ id: "1", title: "Hello from the server" },
{ id: "2", title: "This survives refresh (until the server restarts)" },
];
export function getPosts() {
return posts;
}
export function getPostById(id) {
return posts.find((post) => post.id === id) || null;
}
export function createPost(title) {
const post = { id: crypto.randomUUID(), title };
posts = [post, ...posts];
return post;
}
export function updatePostTitle(postId, title) {
posts = posts.map((post) =>
post.id === postId ? { ...post, title } : post
);
}
export function deletePost(postId) {
posts = posts.filter((post) => post.id !== postId);
}And here's the complete Server Actions file with all operations:
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { createPost, deletePost, updatePostTitle } from "./lib/posts-store";
const createPostSchema = z.object({
title: z
.string()
.trim()
.min(1, "Title is required")
.max(80, "Title is too long"),
});
export async function createPostAction(prevState, formData) {
const cookieStore = await cookies();
const raw = Object.fromEntries(formData);
const result = createPostSchema.safeParse(raw);
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
return {
message: "Please fix the errors and try again.",
fieldErrors: { title: fieldErrors.title?.[0] || "" },
};
}
const post = createPost(result.data.title);
revalidatePath("/");
cookieStore.set("lastCreatedTitle", post.title);
return {
message: "Post added.",
fieldErrors: { title: "" },
};
}
export async function updatePostTitleAction(postId, formData) {
const raw = Object.fromEntries(formData);
const result = createPostSchema.safeParse(raw);
if (!result.success) return;
updatePostTitle(postId, result.data.title);
revalidatePath("/");
}
export async function deletePostAction(postId) {
deletePost(postId);
revalidatePath("/");
}Best Practices
- Always validate input on the server, even if you validate on the client
- Use Zod or similar libraries for type-safe validation
- Return clear error messages that help users fix issues
- Use `revalidatePath` or `revalidateTag` after mutations to keep data fresh
- Handle loading states with `useFormStatus` for better UX
- Use `useActionState` for form state management
- Keep Server Actions focused on a single responsibility
- Use TypeScript for type safety in Server Actions
- Test your forms with JavaScript disabled to ensure progressive enhancement
Conclusion
Server Actions have become my go-to solution for form handling in Next.js. They eliminate the need for API routes, simplify error handling, and provide progressive enhancement out of the box. Once you get used to them, going back to the old way feels unnecessarily complicated.
The examples in this guide cover everything you need to get started: basic forms, validation, loading states, updates, deletes, cookies, and redirects. Start with the simple examples and gradually add more complexity as you need it. Before you know it, you'll be building forms faster than ever.
Related Articles
Next.js Caching and Rendering: Complete Guide
Master Next.js caching strategies including Data Cache, Route Cache, and Request Memoization.
React Hook Form with Zod Validation: Complete Guide
Learn how to implement form validation in React using React Hook Form and Zod.
TypeScript with React: Best Practices and Patterns
Learn TypeScript best practices for React development with type definitions and interfaces.
Express.js REST API Setup: Complete Guide with Error Handling
Learn how to set up a production-ready Express.js REST API with CORS and error handling.