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.
π Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.
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.
For more advanced form validation patterns, check out my React Hook Form with Zod validation guide.
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 { 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, server-side validation is essential for security
- Use Zod or similar libraries - For type-safe validation that matches your TypeScript types
- Return clear error messages - Help users understand what went wrong and how to fix it
-
Use
revalidatePathorrevalidateTag- After mutations to keep data fresh. Learn more about Next.js caching strategies -
Handle loading states - Use
useFormStatusfor better UX -
Use
useActionState- For form state management - Keep Server Actions focused - Single responsibility principle applies here too
- Use TypeScript - For type safety in Server Actions
- Test progressive enhancement - Test your forms with JavaScript disabled
Common Patterns
Pattern 1: Form with Validation
// Server Action
export async function submitForm(prevState, formData) {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// Process data
return { success: true };
}
// Client Component
const [state, formAction] = useActionState(submitForm, { errors: {} });
Pattern 2: Multiple Actions in One Form
<form action={updateAction.bind(null, id)}>
<input name="title" />
<button type="submit">Update</button>
<button type="submit" formAction={deleteAction.bind(null, id)}>
Delete
</button>
</form>
Pattern 3: Inline Actions
export default async function Page() {
async function handleSubmit(formData) {
"use server";
// Process form
}
return <Form action={handleSubmit}>...</Form>;
}
Resources and Further Reading
- π Full Next.js Server Actions Guide - Complete tutorial with advanced examples, troubleshooting, and best practices
- Next.js Documentation - Official Next.js documentation
- Next.js Server Actions - Official Server Actions documentation
- Next.js Caching and Rendering Guide - Learn about revalidatePath and revalidateTag
- React Hook Form with Zod Validation - Client-side form validation patterns
- TypeScript with React Best Practices - TypeScript patterns for React
- Redux Toolkit RTK Query Guide - State management patterns
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.
Key Takeaways:
- Server Actions eliminate the need for API routes
- Progressive enhancement works automatically
- Use
useActionStatefor form state management - Use
useFormStatusfor loading states - Always validate on the server with Zod
- Use
revalidatePathorrevalidateTagafter mutations - Test with JavaScript disabled to ensure progressive enhancement
What's your experience with Next.js Server Actions? Share your tips and tricks in the comments below! π
π‘ Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.
If you found this guide helpful, consider checking out my other articles on Next.js development and web development best practices.
Top comments (0)