Introduction: Server Actions Are Your New Backend
If you've been building Next.js apps for any length of time, you've probably written dozens of API routes. Every single one follows the same tedious pattern: export a handler function, parse the request body, validate the input, check authentication, do the actual work, and return a response. For every form submission, every mutation, every piece of server-side logic, you'd create a new file in /api, wire up the fetch call on the client, and handle the response manually.
Server Actions eliminate all of that ceremony.
Instead of building separate API endpoints, you define async functions that run on the server and call them directly from your components. No fetch calls. No request/response boilerplate. No separate route files for simple mutations. The framework handles the network boundary, serialization, error propagation, and even progressive enhancement — your forms work before JavaScript even loads.
But here's the thing most tutorials completely gloss over: Server Actions are deceptively simple to start with and surprisingly deep when you need production-grade patterns. A basic form submission? Five lines of code. A properly secured, validated, optimistically-updated mutation with error handling and cache invalidation? That takes real architectural thinking.
This guide goes well beyond the basics. We'll cover everything from foundational patterns through advanced techniques — composable middleware with next-safe-action, Zod validation pipelines, optimistic UI updates, file uploads, proper error boundaries, and the security considerations that most developers miss. Whether you're writing your first Server Action or hardening an existing app, you'll walk away with patterns you can use right away.
Defining Server Actions: Inline vs. Dedicated Files
There are two ways to define Server Actions, and honestly, choosing the right one matters more than you'd think as your application grows.
Inline Server Actions
The simplest approach is to define Server Actions inline within a Server Component using the "use server" directive at the top of the function body:
// app/contact/page.tsx
export default function ContactPage() {
async function submitForm(formData: FormData) {
"use server";
const email = formData.get("email") as string;
const message = formData.get("message") as string;
await db.insert(contacts).values({ email, message });
}
return (
<form action={submitForm}>
<input type="email" name="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}
This works perfectly for one-off forms — a contact page, a newsletter signup, a quick feedback widget. The action lives right next to the form that uses it, making the code easy to follow. And because the component is a Server Component, the function body runs entirely on the server. The client never sees your database queries or API keys.
Dedicated Action Files
For anything you'll reuse across components (or for actions with complex logic), put them in dedicated files with "use server" at the top:
// app/actions/auth.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { verifyPassword, createSession } from "@/lib/auth";
export async function login(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const user = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user || !await verifyPassword(password, user.passwordHash)) {
return { error: "Invalid email or password" };
}
const session = await createSession(user.id);
const cookieStore = await cookies();
cookieStore.set("session", session.token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
});
redirect("/dashboard");
}
export async function logout() {
const cookieStore = await cookies();
cookieStore.delete("session");
redirect("/login");
}
When the "use server" directive sits at the top of a file, every exported function in that file becomes a Server Action. This is the recommended pattern for production apps — it keeps your actions organized, testable, and easy to import from both Server and Client Components.
Calling Server Actions from Client Components
Here's a critical detail that trips people up: Client Components can import and use Server Actions from dedicated files, but they can't define inline Server Actions. This actually makes sense if you think about it — the "use client" directive means the component code ships to the browser, and you definitely don't want server logic bundled into client-side JavaScript:
// app/components/LoginForm.tsx
"use client";
import { login } from "@/app/actions/auth";
import { useActionState } from "react";
export function LoginForm() {
const [state, formAction, isPending] = useActionState(login, null);
return (
<form action={formAction}>
<input type="email" name="email" required />
<input type="password" name="password" required />
{state?.error && <p className="text-red-500">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Signing in..." : "Sign In"}
</button>
</form>
);
}
Form Handling with useActionState and useFormStatus
React 19 introduced two hooks that genuinely transform how you handle form submissions. Understanding when to use each one — and how they differ — is essential for building polished user experiences.
useActionState: Full Form State Management
The useActionState hook (imported from react) wraps a Server Action and gives you three things: the current state (whatever the last action invocation returned), a wrapped action function to pass to your form, and a pending boolean:
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions/posts";
type FormState = {
errors?: {
title?: string[];
content?: string[];
};
message?: string;
success?: boolean;
} | null;
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState<FormState, FormData>(
createPost,
null
);
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
{state?.errors?.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
{state?.errors?.content && (
<p className="text-red-500">{state.errors.content[0]}</p>
)}
</div>
{state?.message && (
<p className={state.success ? "text-green-500" : "text-red-500"}>
{state.message}
</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
</form>
);
}
The key insight here is that useActionState gives you a feedback loop. Your action returns state, the component re-renders with that state, and the user sees validation errors or success messages. The action function signature changes slightly though — it receives the previous state as its first argument, followed by the form data:
// app/actions/posts.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const createPostSchema = 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 async function createPost(prevState: any, formData: FormData) {
const rawData = {
title: formData.get("title"),
content: formData.get("content"),
};
const validated = createPostSchema.safeParse(rawData);
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: "Validation failed",
success: false,
};
}
try {
await db.insert(posts).values(validated.data);
revalidatePath("/posts");
return { message: "Post created successfully!", success: true };
} catch (error) {
return { message: "Failed to create post", success: false };
}
}
useFormStatus: Lightweight Pending Indicators
The useFormStatus hook (imported from react-dom) is simpler and more focused. It gives you the pending state for the nearest parent <form> element. There's one critical rule you need to remember: it must be rendered as a child of the form, not in the same component that renders the form:
"use client";
import { useFormStatus } from "react-dom";
function SubmitButton({ label = "Submit" }: { label?: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Submitting..." : label}
</button>
);
}
// Usage - SubmitButton is a CHILD of the form
export function NewsletterForm() {
return (
<form action={subscribe}>
<input type="email" name="email" required />
<SubmitButton label="Subscribe" />
</form>
);
}
So when should you use which? Use useFormStatus when you only need a loading indicator and don't care about the action's return value. Use useActionState when you need the full state cycle — validation errors, success messages, or any structured response from the server.
Input Validation with Zod
Let me be blunt: server-side validation isn't optional. It's your primary defense. Client-side validation is purely for user experience — nothing more. Anyone with basic HTTP knowledge can bypass your React form and POST arbitrary data directly to your Server Action endpoint. Zod has become the go-to standard for schema validation in Next.js apps, and for good reason: it's TypeScript-native, composable, and gives you excellent error messages out of the box.
Building a Validation Layer
Start by defining schemas that match your form fields, then create a reusable validation pattern:
// lib/schemas/user.ts
import { z } from "zod";
export const updateProfileSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(50, "Name must be under 50 characters"),
email: z.string().email("Please enter a valid email address"),
bio: z
.string()
.max(500, "Bio must be under 500 characters")
.optional()
.or(z.literal("")),
website: z
.string()
.url("Please enter a valid URL")
.optional()
.or(z.literal("")),
});
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
// app/actions/profile.ts
"use server";
import { updateProfileSchema } from "@/lib/schemas/user";
import { getSession } from "@/lib/auth";
import { revalidatePath } from "next/cache";
export async function updateProfile(prevState: any, formData: FormData) {
// 1. Authentication
const session = await getSession();
if (!session) {
return { message: "You must be logged in", success: false };
}
// 2. Parse form data
const rawData = Object.fromEntries(formData);
// 3. Validate
const validated = updateProfileSchema.safeParse(rawData);
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: "Please fix the errors below",
success: false,
};
}
// 4. Authorize — ensure users can only update their own profile
const existingProfile = await db.query.profiles.findFirst({
where: eq(profiles.userId, session.userId),
});
if (!existingProfile) {
return { message: "Profile not found", success: false };
}
// 5. Mutate
try {
await db
.update(profiles)
.set(validated.data)
.where(eq(profiles.userId, session.userId));
revalidatePath("/profile");
return { message: "Profile updated!", success: true };
} catch (error) {
return { message: "Failed to update profile", success: false };
}
}
Notice the five-step pattern: authenticate, parse, validate, authorize, then mutate. This order matters more than you might think. You check authentication first because there's no point validating input from an unauthenticated user. You validate before authorizing because authorization checks often hit the database, and you want to reject malformed input before incurring that cost.
Security: Treating Server Actions as Public Endpoints
This is the most important section of this entire guide, and frankly, the one most developers get wrong. Server Actions are not protected by your component tree. They are public HTTP POST endpoints that anyone can call directly with any payload.
Let that sink in for a moment.
The Five Security Vulnerabilities You Must Address
1. Missing Authentication: Every Server Action that performs a mutation must verify the user's identity. Don't assume that because a component is only rendered for logged-in users, the action is somehow safe:
// DANGEROUS - no auth check
export async function deletePost(formData: FormData) {
"use server";
const postId = formData.get("postId") as string;
await db.delete(posts).where(eq(posts.id, postId)); // Anyone can delete any post!
}
// SECURE - auth + authorization
export async function deletePost(formData: FormData) {
"use server";
const session = await getSession();
if (!session) throw new Error("Unauthorized");
const postId = formData.get("postId") as string;
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
});
if (!post || post.authorId !== session.userId) {
throw new Error("Forbidden");
}
await db.delete(posts).where(eq(posts.id, postId));
revalidatePath("/posts");
}
2. Missing Input Validation: Never trust form data. Always validate with a schema library like Zod before using any input in database queries or business logic. I've seen production apps that skip this step, and it never ends well.
3. Missing Authorization: Authentication tells you who the user is. Authorization tells you what they're allowed to do. These are different things! A logged-in user shouldn't be able to delete another user's post just because they can craft the right POST request.
4. Data Exposure: Server Actions can return data to the client. Be careful not to leak sensitive information in your return values — don't return full database records with internal IDs, password hashes, or tokens. Shape your response objects deliberately.
5. CSRF and Rate Limiting: Next.js provides some CSRF protection by default (comparing Origin and Host headers, using SameSite cookies, and creating encrypted action IDs). However, you should still implement rate limiting for actions that send emails, process payments, or perform expensive operations.
Composable Middleware with next-safe-action
When you find yourself copying and pasting authentication checks, validation, and error handling across dozens of Server Actions, it's time to reach for next-safe-action. This library gives you a type-safe action client with composable middleware, similar to how tRPC works for API routes.
Setting Up the Action Client
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { getSession } from "@/lib/auth";
// Base client with error handling
export const actionClient = createSafeActionClient({
handleServerError(error) {
console.error("Action error:", error.message);
return "Something went wrong. Please try again.";
},
});
// Authenticated client - adds session to context
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await getSession();
if (!session) {
throw new Error("You must be logged in to perform this action");
}
return next({
ctx: { session },
});
});
// Admin client - extends authenticated with role check
export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
if (ctx.session.role !== "admin") {
throw new Error("Admin access required");
}
return next({ ctx });
});
Defining Actions with Middleware
// app/actions/posts.ts
"use server";
import { authActionClient } from "@/lib/safe-action";
import { z } from "zod";
import { revalidatePath } from "next/cache";
export const createPost = authActionClient
.schema(
z.object({
title: z.string().min(3).max(200),
content: z.string().min(10),
categoryId: z.string().uuid(),
})
)
.action(async ({ parsedInput, ctx }) => {
// ctx.session is available and typed — middleware guarantees it
const post = await db.insert(posts).values({
...parsedInput,
authorId: ctx.session.userId,
}).returning();
revalidatePath("/posts");
return { post: post[0] };
});
export const deletePost = authActionClient
.schema(z.object({ postId: z.string().uuid() }))
.action(async ({ parsedInput, ctx }) => {
const post = await db.query.posts.findFirst({
where: eq(posts.id, parsedInput.postId),
});
if (!post || post.authorId !== ctx.session.userId) {
throw new Error("You can only delete your own posts");
}
await db.delete(posts).where(eq(posts.id, parsedInput.postId));
revalidatePath("/posts");
return { success: true };
});
Adding Rate Limiting Middleware
// lib/safe-action.ts (extended)
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "60 s"),
});
export const rateLimitedActionClient = authActionClient.use(
async ({ ctx, next }) => {
const { success, remaining } = await ratelimit.limit(ctx.session.userId);
if (!success) {
throw new Error("Too many requests. Please slow down.");
}
return next({
ctx: { ...ctx, rateLimitRemaining: remaining },
});
}
);
The real beauty of this pattern is composability. You build up layers of middleware — authentication, authorization, rate limiting, logging — and each action only needs to declare which client it uses. The middleware chain runs automatically, and each layer can enrich the context with typed data that downstream middleware and the action itself can access. Once you start using this pattern, you won't want to go back.
Optimistic Updates with useOptimistic
When a user clicks "Like" on a post, they shouldn't have to wait for the server to respond before seeing the UI update. That lag (even a few hundred milliseconds) makes an app feel sluggish. Optimistic updates show the expected result immediately and reconcile with the server response when it arrives. React 19's useOptimistic hook makes this pattern surprisingly straightforward:
"use client";
import { useOptimistic } from "react";
import { toggleLike } from "@/app/actions/likes";
type Post = {
id: string;
title: string;
likes: number;
isLiked: boolean;
};
export function PostCard({ post }: { post: Post }) {
const [optimisticPost, setOptimisticPost] = useOptimistic(
post,
(currentPost, newLikeState: boolean) => ({
...currentPost,
isLiked: newLikeState,
likes: newLikeState ? currentPost.likes + 1 : currentPost.likes - 1,
})
);
async function handleLike() {
const newState = !optimisticPost.isLiked;
setOptimisticPost(newState); // Update UI immediately
await toggleLike(post.id); // Server call happens in background
}
return (
<div className="border rounded-lg p-4">
<h3>{optimisticPost.title}</h3>
<button onClick={handleLike}>
{optimisticPost.isLiked ? "❤️" : "🤍"} {optimisticPost.likes}
</button>
</div>
);
}
If the server action fails, React automatically reverts the optimistic update. The UI bounces back to its previous state, and you can show an error message via a try-catch or error boundary. This pattern is particularly effective for interactions users expect to be instantaneous — likes, bookmarks, todo checkboxes, drag-and-drop reordering.
Optimistic Updates in Lists
For adding items to a list optimistically, you can combine useOptimistic with a form action:
"use client";
import { useOptimistic, useRef } from "react";
import { useActionState } from "react";
import { addComment } from "@/app/actions/comments";
type Comment = {
id: string;
text: string;
author: string;
createdAt: string;
};
export function CommentSection({
postId,
comments,
}: {
postId: string;
comments: Comment[];
}) {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(current, newComment: Comment) => [...current, newComment]
);
async function handleSubmit(formData: FormData) {
const text = formData.get("text") as string;
addOptimisticComment({
id: crypto.randomUUID(),
text,
author: "You",
createdAt: new Date().toISOString(),
});
formRef.current?.reset();
await addComment(postId, formData);
}
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li key={comment.id}>
<strong>{comment.author}</strong>: {comment.text}
</li>
))}
</ul>
<form ref={formRef} action={handleSubmit}>
<input type="text" name="text" required />
<button type="submit">Add Comment</button>
</form>
</div>
);
}
Cache Invalidation: revalidatePath vs. revalidateTag
After a Server Action mutates data, you need to tell Next.js which cached content is now stale. There are two approaches, and picking the right one depends on how your data maps to your routes.
revalidatePath: Route-Level Invalidation
Use revalidatePath when a mutation affects a specific page or a section of your route hierarchy:
"use server";
import { revalidatePath } from "next/cache";
export async function updatePost(postId: string, formData: FormData) {
await db.update(posts).set({ title: formData.get("title") }).where(eq(posts.id, postId));
// Invalidate the specific post page
revalidatePath(`/posts/${postId}`);
// Invalidate the posts listing too
revalidatePath("/posts");
// Invalidate an entire layout (all child routes)
revalidatePath("/dashboard", "layout");
}
revalidateTag: Data-Level Invalidation
Use revalidateTag when you want to invalidate all cached data with a specific tag, regardless of which routes use it. This is more granular and typically more efficient for shared data:
// Data fetching with tags
async function getPosts() {
"use cache";
cacheLife("hours");
cacheTag("posts"); // Tag this cached data
return db.query.posts.findMany();
}
async function getPost(id: string) {
"use cache";
cacheLife("hours");
cacheTag("posts", `post-${id}`); // Multiple tags
return db.query.posts.findFirst({ where: eq(posts.id, id) });
}
// Server Action that invalidates by tag
export async function publishPost(postId: string) {
"use server";
await db.update(posts).set({ status: "published" }).where(eq(posts.id, postId));
// Invalidate all data tagged with "posts" across all routes
revalidateTag("posts");
// Or target a specific post
revalidateTag(`post-${postId}`);
}
Here's a practical rule of thumb: use revalidatePath when you think in terms of "which pages need to update" and revalidateTag when you think in terms of "which data changed." In practice, revalidateTag tends to be more flexible because data often appears on multiple routes, and tagging lets you invalidate all of them with a single call.
One important gotcha that catches people off guard: always call revalidatePath or revalidateTag outside your try-catch block if you want it to run even when an error occurs, or inside the try block if revalidation should only happen on success. And if you're using redirect(), call it after revalidation and outside the try-catch — redirect() works by throwing an internal error, and catching it will silently swallow the redirect. I've debugged this issue more times than I'd like to admit.
File Uploads with Server Actions
Handling file uploads through Server Actions is actually pretty straightforward because the FormData API natively supports File objects. Here's a complete pattern for uploading files with validation:
// app/actions/upload.ts
"use server";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
import { z } from "zod";
import { getSession } from "@/lib/auth";
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const uploadSchema = z.object({
alt: z.string().min(1, "Alt text is required").max(200),
});
export async function uploadImage(prevState: any, formData: FormData) {
const session = await getSession();
if (!session) return { error: "Unauthorized" };
const file = formData.get("file") as File | null;
if (!file || file.size === 0) {
return { error: "Please select a file" };
}
// Validate file type and size
if (!ALLOWED_TYPES.includes(file.type)) {
return { error: "Only JPEG, PNG, and WebP images are allowed" };
}
if (file.size > MAX_FILE_SIZE) {
return { error: "File must be under 5MB" };
}
// Validate other form fields
const validated = uploadSchema.safeParse({
alt: formData.get("alt"),
});
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors };
}
try {
const bytes = await file.arrayBuffer();
const buffer = new Uint8Array(bytes);
const uniqueName = `${crypto.randomUUID()}${path.extname(file.name)}`;
const uploadDir = path.join(process.cwd(), "public", "uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, uniqueName), buffer);
const imageUrl = `/uploads/${uniqueName}`;
await db.insert(images).values({
url: imageUrl,
alt: validated.data.alt,
uploadedBy: session.userId,
});
return { success: true, imageUrl };
} catch (error) {
return { error: "Upload failed. Please try again." };
}
}
For production applications, you'll typically want to upload to a cloud storage service like S3 or Cloudinary rather than the local filesystem. The pattern stays the same though — validate the file, process the bytes, upload to your storage provider, and save the reference in your database.
Client-Side Upload Form with Preview
"use client";
import { useActionState, useState } from "react";
import { uploadImage } from "@/app/actions/upload";
export function ImageUploadForm() {
const [state, formAction, isPending] = useActionState(uploadImage, null);
const [preview, setPreview] = useState<string | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => setPreview(reader.result as string);
reader.readAsDataURL(file);
}
}
return (
<form action={formAction}>
<div>
<input
type="file"
name="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
required
/>
{preview && (
<img src={preview} alt="Preview" className="w-32 h-32 object-cover" />
)}
</div>
<div>
<label htmlFor="alt">Alt Text</label>
<input id="alt" name="alt" required />
</div>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Image uploaded!</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Uploading..." : "Upload Image"}
</button>
</form>
);
}
Error Handling Strategies
Robust error handling in Server Actions requires thinking in layers. You need to handle expected errors (validation failures, business rule violations) very differently from unexpected errors (database outages, network failures).
Returning Errors vs. Throwing Errors
For expected errors, return them as part of the action's response. This keeps the user on the same page with their form data intact:
export async function transferFunds(prevState: any, formData: FormData) {
"use server";
const amount = parseFloat(formData.get("amount") as string);
const toAccount = formData.get("toAccount") as string;
const balance = await getAccountBalance(session.userId);
if (amount > balance) {
return {
error: `Insufficient funds. Your balance is $${balance.toFixed(2)}.`,
success: false,
};
}
// ... process transfer
}
For unexpected errors, throw them and let Next.js error boundaries catch them. This triggers the nearest error.tsx boundary:
// app/dashboard/error.tsx
"use client";
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-6 text-center">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
This two-tier approach gives you fine-grained control: expected errors produce inline feedback, while unexpected errors trigger a recoverable error state without losing the page layout. It's a clean separation that keeps your UX consistent.
Progressive Enhancement: Forms That Work Without JavaScript
One of the most underappreciated features of Server Actions is progressive enhancement. When you use the action attribute on a form element with a Server Action, the form works as a standard HTML form submission even if JavaScript hasn't loaded or is disabled. The form submits as a POST request, the action runs on the server, and the page refreshes with the updated state.
Why does this matter? A few reasons:
- Accessibility: Users with assistive technologies or limited JavaScript support can still use your forms.
- Performance: Forms are interactive before hydration completes. On slow connections or low-powered devices, this can make a real difference.
- Reliability: If a JavaScript error breaks your client-side bundle, the form still works. That's a nice safety net.
- SEO crawlers: Bots that don't execute JavaScript can still interact with your forms.
To preserve progressive enhancement, follow these guidelines:
// Progressive enhancement - this form works with OR without JS
export default function SearchPage() {
async function search(formData: FormData) {
"use server";
const query = formData.get("q") as string;
redirect(`/search?q=${encodeURIComponent(query)}`);
}
return (
<form action={search}>
<input type="text" name="q" placeholder="Search..." />
<button type="submit">Search</button>
</form>
);
}
The key constraints: use the action attribute (not onSubmit), rely on native form elements with name attributes, and handle state through Server Action return values rather than client-side state management. When JavaScript loads, React enhances the experience with pending states, optimistic updates, and client-side navigation — but the core functionality works without any of it.
Server Actions vs. API Routes: When to Use Which
Server Actions don't replace API Routes entirely. They serve different purposes, and understanding where to draw the line helps you architect your application correctly.
Use Server Actions for:
- Form submissions and mutations triggered by user interaction
- Data mutations that need to revalidate cached content
- Operations that are tightly coupled to your UI components
- Any mutation that benefits from progressive enhancement
Use API Routes (Route Handlers) for:
- Webhook endpoints that receive callbacks from external services
- Public APIs consumed by third-party applications or mobile apps
- Endpoints that need specific HTTP methods (GET, PUT, DELETE, PATCH)
- Long-running operations with streaming responses
- Integration points that other systems call programmatically
Server Actions use POST exclusively and can't be cached. If you need a GET endpoint, use a Route Handler. If you need an endpoint that external services call (Stripe webhooks, GitHub webhooks, OAuth callbacks), use a Route Handler. For everything else — form submissions, button clicks, in-app mutations — Server Actions are almost always the better choice.
Testing Server Actions
Here's some good news: Server Actions are just async functions, which makes them pretty straightforward to test in isolation. The key is to extract your validation schemas and business logic into testable units:
// __tests__/actions/posts.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock dependencies
vi.mock("@/lib/auth", () => ({
getSession: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
describe("createPost", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should reject unauthenticated requests", async () => {
const { getSession } = await import("@/lib/auth");
vi.mocked(getSession).mockResolvedValue(null);
const { createPost } = await import("@/app/actions/posts");
const formData = new FormData();
formData.set("title", "Test Post");
formData.set("content", "Test content that is long enough");
const result = await createPost(null, formData);
expect(result.success).toBe(false);
expect(result.message).toContain("logged in");
});
it("should validate input with Zod schema", async () => {
const { getSession } = await import("@/lib/auth");
vi.mocked(getSession).mockResolvedValue({ userId: "1", role: "user" });
const { createPost } = await import("@/app/actions/posts");
const formData = new FormData();
formData.set("title", "Ab"); // Too short
formData.set("content", "Short"); // Too short
const result = await createPost(null, formData);
expect(result.success).toBe(false);
expect(result.errors?.title).toBeDefined();
expect(result.errors?.content).toBeDefined();
});
});
For integration tests, use Playwright or Cypress to test the full form submission flow, including progressive enhancement (try testing with JavaScript disabled) and optimistic updates.
Performance Considerations
Server Actions introduce a network round-trip for every invocation. While this is usually fine for form submissions, it can become a problem for frequent operations. Here are some patterns to keep things snappy:
- Debounce frequent actions: For autosave or live search, debounce the Server Action call on the client side to avoid flooding the server with requests.
- Batch related mutations: If multiple updates happen together (like reordering a list), send them as a single Server Action call with an array of changes rather than one call per item.
- Use optimistic updates: As we covered earlier,
useOptimisticmakes the UI feel instant even when the server round-trip takes a few hundred milliseconds. - Keep actions focused: Each Server Action should do one thing well. Don't overload a single action with multiple unrelated mutations — split them into separate actions that can be called independently.
- Mind the payload size: Server Action arguments and return values get serialized. Avoid passing large objects or arrays when a reference (like an ID) would do the job.
Conclusion: Building with Confidence
Server Actions fundamentally change how you build interactive Next.js applications. They collapse the gap between your UI and your server logic, eliminating API route boilerplate while preserving — and even improving — the security model. But that simplicity can be deceptive. Production-grade Server Actions demand the same rigor you'd apply to any public API endpoint: authentication, authorization, validation, error handling, and rate limiting.
The patterns in this guide should give you a solid foundation. Start with inline actions for simple forms, graduate to dedicated files as your application grows, and adopt next-safe-action when you need composable middleware. Use Zod for validation. Use useActionState for form state management. Use useOptimistic for instant-feeling interactions. And always — always — treat your Server Actions as public endpoints, because that's exactly what they are.
The best part? All of this works with progressive enhancement out of the box. Your forms submit before JavaScript loads. Your mutations run on the server where they belong. And your users get a fast, reliable experience regardless of their network conditions or device capabilities. That's what Server Actions bring to the table, and in my experience, it really delivers.