What Are Server Actions (And Why Should You Care)?
Back when I first started building full-stack apps with Next.js, every form submission meant creating a separate API route — a /api/submit-form endpoint, then fetching it from the client, handling loading states, errors… you get the idea. It worked, but it felt like a lot of boilerplate for something that should be simple.
Server Actions change that completely. They let you write server-side functions that run directly from your React components — no API route needed, no manual fetch() calls. The framework handles the network layer for you.
Quick Start: Your First Server Action in 5 Minutes
You need Next.js 14+ with the App Router. If you already have a project, you’re ready. If not, spin one up:
npx create-next-app@latest my-app --typescript --app
cd my-app
npm run dev
Creating a Server Action
Add "use server" at the top of a function to mark it as a Server Action:
// app/actions.ts
"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 (example with Prisma)
await db.post.create({
data: { title, content }
});
}
Then use it directly in a form — no onSubmit handler, no fetch():
// app/new-post/page.tsx
import { createPost } from "@/app/actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Content..." required />
<button type="submit">Create Post</button>
</form>
);
}
That’s it. When the user submits, Next.js serializes the form data and calls createPost on the server. No API route, no client-side fetch — it just works.
Deep Dive: How Server Actions Actually Work
The Network Layer Is Invisible
Under the hood, Next.js automatically creates a POST endpoint for each Server Action. When your form submits, the browser sends a multipart/form-data request to that endpoint. The response comes back and React re-renders as needed. You never configure this endpoint, and users can’t easily enumerate it the way they could with a predictable /api/create-post URL.
Tracking Loading State and Return Values
Use the useActionState hook (available in Next.js 15+ / React 19) to handle pending state and action responses:
"use client"
import { useActionState } from "react";
import { createPost } from "@/app/actions";
const initialState = { message: "", error: "" };
export default function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Content..." required />
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Create Post"}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.message && <p className="success">{state.message}</p>}
</form>
);
}
Update the action to accept and return state:
"use server"
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
if (!title || title.length < 3) {
return { error: "Title must be at least 3 characters", message: "" };
}
try {
await db.post.create({ data: { title, content } });
return { message: "Post created!", error: "" };
} catch {
return { error: "Failed to save post", message: "" };
}
}
Revalidating Data After Mutations
After creating or updating data, you’ll want the cached pages to reflect the change. Use revalidatePath:
"use server"
import { revalidatePath } from "next/cache";
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get("title") as string;
await db.post.create({ data: { title, content: "" } });
revalidatePath("/posts"); // Pages fetching from /posts get fresh data
return { message: "Post created!" };
}
Advanced Usage: Validation, Auth, and Optimistic UI
Input Validation with Zod
Never trust what comes in through FormData. Validate everything on the server before touching your database:
"use server"
import { z } from "zod";
const PostSchema = z.object({
title: z.string().min(3, "Title too short").max(100, "Title too long"),
content: z.string().min(10, "Content too short"),
});
export async function createPost(prevState: any, formData: FormData) {
const result = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!result.success) {
return { error: result.error.errors[0].message };
}
await db.post.create({ data: result.data });
return { message: "Post saved!" };
}
Authentication Checks Inside the Action
This is where Server Actions really earn their place in a security model. Auth checks run on the server — a user with browser DevTools can’t skip them:
"use server"
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { revalidatePath } from "next/cache";
export async function deletePost(postId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new Error("Unauthorized");
}
const post = await db.post.findUnique({ where: { id: postId } });
if (post?.authorId !== session.user.id) {
throw new Error("Forbidden: you don't own this post");
}
await db.post.delete({ where: { id: postId } });
revalidatePath("/posts");
}
Calling Actions from Event Handlers
Server Actions aren’t limited to <form action={...}>. Call them from click handlers too:
"use client"
import { deletePost } from "@/app/actions";
import { startTransition } from "react";
export function DeleteButton({ postId }: { postId: string }) {
function handleDelete() {
startTransition(async () => {
await deletePost(postId);
});
}
return (
<button onClick={handleDelete} className="btn-danger">
Delete
</button>
);
}
Wrapping in startTransition keeps the UI responsive while the server processes the request.
Optimistic Updates for Instant Feedback
Use useOptimistic to update the UI before the server responds — great for like buttons, toggles, and similar fast interactions:
"use client"
import { useOptimistic, startTransition } from "react";
import { toggleLike } from "@/app/actions";
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(current, delta: number) => current + delta
);
async function handleLike() {
startTransition(async () => {
addOptimisticLike(1); // UI updates instantly
await toggleLike(postId); // Server catches up
});
}
return (
<button onClick={handleLike}>
♥ {optimisticLikes}
</button>
);
}
Practical Tips From Someone Who’s Shipped This in Production
In my real-world experience, this is one of the essential skills to master if you’re building full-stack apps with Next.js. After using Server Actions across several projects, here’s what I wish someone had told me earlier.
Put Actions in a Dedicated File
You can define Server Actions inline inside a Server Component, but they’re harder to reuse and test that way. Create an app/actions.ts file (or organize by feature: app/posts/actions.ts) and import where needed. This keeps components clean and makes your actions easy to find.
Client Validation Is for UX, Server Validation Is for Security
Even if you validate in the browser with a Zod schema or HTML5 attributes, re-validate inside the Server Action. Anyone with DevTools can fire arbitrary POST requests at your action endpoint. The server check is the only one that counts.
Only Return What the UI Needs
Be deliberate about what you return from actions. Don’t return full database records with internal IDs, timestamps, and sensitive fields if the UI only needs a success message. Treat action responses the same way you’d treat a public API response.
Set Up an Error Boundary
If a Server Action throws an unhandled error, Next.js renders the nearest error.tsx. Add one at the app layout level as a safety net:
// app/error.tsx
"use client"
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
Start Simple, Layer Complexity Gradually
Don’t try to add auth, validation, optimistic UI, and error handling all at once. Start with a bare "use server" function that saves data. Get that working. Then add Zod validation. Then add auth. Each layer is a small, testable step rather than one overwhelming setup.
Server Actions make full-stack development with Next.js dramatically simpler — fewer files, less boilerplate, and a security model that’s easier to reason about than managing API route authentication separately. The mental shift from “create an API, then fetch it” to “just call the function” is genuinely freeing once it clicks.

