Server Actions are one of the most practical features in Next.js. They let you write server-side functions that are called directly from your components, eliminating the need to create API routes, manage fetch calls, or serialize data manually. A form submission becomes a function call.
The ‘use server’ Directive
Mark a function or a file with 'use server' to define a Server Action. These functions execute on the server and can be passed to forms as the action attribute.
"use server";
import { db } from "@/lib/database";
export async function createPost(formData: FormData) { const title = formData.get("title") as string; const content = formData.get("content") as string;
await db.post.create({ data: { title, content }, });}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 something..." required /> <button type="submit">Publish</button> </form> );}No API route. No fetch call. No JSON.stringify. The form submits directly to the server function. This also works without JavaScript enabled, since the form uses progressive enhancement.
Validation with Zod
Raw FormData should never be trusted. Use Zod to validate and parse the input, returning structured errors when validation fails.
"use server";
import { z } from "zod";import { db } from "@/lib/database";
const PostSchema = z.object({ title: z.string().min(3, "Title must be at least 3 characters"), content: z.string().min(10, "Content must be at least 10 characters"),});
export type FormState = { errors?: Record<string, string[]>; message?: string;};
export async function createPost( prevState: FormState, formData: FormData): Promise<FormState> { const result = PostSchema.safeParse({ title: formData.get("title"), content: formData.get("content"), });
if (!result.success) { return { errors: result.error.flatten().fieldErrors }; }
await db.post.create({ data: result.data }); return { message: "Post created successfully" };}The prevState parameter enables the action to work with useActionState, which tracks form state across submissions.
Using useActionState for Error Display
The useActionState hook connects your Server Action to component state, giving you access to validation errors and pending status.
"use client";
import { useActionState } from "react";import { createPost, type FormState } from "@/app/actions";
export function PostForm() { const [state, formAction, isPending] = useActionState<FormState, FormData>( createPost, {} );
return ( <form action={formAction}> <div> <input name="title" placeholder="Post title" /> {state.errors?.title && ( <p className="text-red-500">{state.errors.title[0]}</p> )} </div> <div> <textarea name="content" placeholder="Write something..." /> {state.errors?.content && ( <p className="text-red-500">{state.errors.content[0]}</p> )} </div> <button type="submit" disabled={isPending}> {isPending ? "Publishing..." : "Publish"} </button> {state.message && <p className="text-green-500">{state.message}</p>} </form> );}Optimistic Updates with useOptimistic
For actions where latency is noticeable, useOptimistic lets you update the UI immediately while the server processes the request.
"use client";
import { useOptimistic } from "react";import { toggleLike } from "@/app/actions";
export function LikeButton({ liked, count }: { liked: boolean; count: number }) { const [optimistic, setOptimistic] = useOptimistic( { liked, count }, (current, newLiked: boolean) => ({ liked: newLiked, count: current.count + (newLiked ? 1 : -1), }) );
async function handleClick() { setOptimistic(!optimistic.liked); await toggleLike(); }
return ( <button onClick={handleClick}> {optimistic.liked ? "Unlike" : "Like"} ({optimistic.count}) </button> );}The UI updates instantly. If the server action fails, React reverts to the actual state.
Revalidation After Mutations
After modifying data, you need to tell Next.js to refresh the cached content. Use revalidatePath or revalidateTag inside your Server Action.
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(prevState: FormState, formData: FormData) { // ... validation and database insert
revalidatePath("/posts"); return { message: "Post created" };}revalidatePath("/posts") invalidates the cached data for that route, so the next visit shows the new post. For more granular control, tag your fetch calls and use revalidateTag("posts") to invalidate only specific data.
Compared to the API Route Approach
With traditional API routes, you would create a POST /api/posts endpoint, call fetch from the client, handle the response, manage loading and error states manually, and coordinate cache invalidation. Server Actions collapse all of this into a single function that the framework handles end to end.
Server Actions are not a replacement for every API route. Public APIs consumed by external clients still need explicit endpoints. But for internal form submissions and mutations, Server Actions remove unnecessary boilerplate and provide type safety from form to database in a single file.