Server Actions in Next.js
Server Actions in Next.js
LinkedIn Hook
You're building a form in Next.js. Your instinct says: create an API route, write a POST handler, call
fetchfrom the client, parse the response, handle errors, show loading state.That's 6 steps. Server Actions reduce it to 1.
You write a function with
"use server"at the top, pass it to a<form action={...}>, and Next.js handles the network layer for you — serialization, deserialization, error boundaries, even progressive enhancement so the form works without JavaScript.But here's what most tutorials skip: Server Actions are not just a convenience wrapper. They fundamentally change how you think about mutations in React. They integrate with
revalidatePathandrevalidateTagto bust your cache after writes. They run exclusively on the server, so you can safely access your database, call internal services, and use secrets — all from a function that looks like a normal async function.And if you don't validate inputs on the server side, you've just created an open RPC endpoint that anyone can call.
In this lesson, I break down exactly how Server Actions work, when to use them, how to call them from both server and client components, and the security mistakes that will get you burned in production.
Read the full lesson → [link]
#NextJS #React #ServerActions #JavaScript #InterviewPrep #Frontend #WebDev #FullStack #100DaysOfCode
What You'll Learn
- What Server Actions are and what
"use server"means - How to handle forms without creating API routes
- Inline vs module-level Server Action declarations
- How to call Server Actions from Client Components
- Progressive enhancement — forms that work without JavaScript
- Cache revalidation after mutations with
revalidatePathandrevalidateTag - Security considerations — why input validation is non-negotiable
The Concept — What Are Server Actions?
Analogy: The Direct Phone Line
Imagine you work in a company where every request to the finance department goes through a receptionist. You call the receptionist (API route), describe what you need, the receptionist translates your request into the finance department's language, walks it over, gets the answer, and calls you back.
Server Actions are like getting a direct phone line to the finance department. You pick up the phone, make your request, and get your answer — no intermediary. The phone system (Next.js) handles all the routing, security, and translation behind the scenes.
But here's the catch: just because you have a direct line doesn't mean the finance department should trust everything you say. They still need to verify your identity and validate your numbers. That's server-side validation — and skipping it is the number one security mistake with Server Actions.
"use server" — The Directive Explained
The "use server" directive marks a function (or an entire module) as a Server Action. It tells Next.js: "This function runs exclusively on the server, but can be invoked from the client via a special RPC-like mechanism."
When you use "use server", Next.js:
- Creates a unique HTTP endpoint for that function automatically
- Serializes the arguments from the client
- Executes the function on the server
- Serializes the return value back to the client
- Integrates with React's transition system for pending states
The function never appears in the client bundle. Its code stays on the server.
Inline vs Module-Level Server Actions
There are two ways to declare Server Actions, and the distinction matters.
Inline Server Actions (inside a Server Component)
// app/posts/page.tsx — this is a Server Component (no "use client")
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export default function PostsPage() {
// Define the action directly inside the server component
async function createPost(formData: FormData) {
"use server";
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// Direct database access — this runs on the server
await db.post.create({
data: { title, content },
});
// Bust the cache so the page shows the new post
revalidatePath("/posts");
}
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write something..." required />
<button type="submit">Create Post</button>
</form>
);
}
Key point: Inline actions can only be defined inside Server Components. The "use server" directive goes inside the function body, not at the top of the file.
Module-Level Server Actions (separate file)
// app/actions/post-actions.ts
"use server";
// The directive at the top of the file makes EVERY exported function a Server Action
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
});
export async function createPost(formData: FormData) {
const raw = {
title: formData.get("title"),
content: formData.get("content"),
};
// Validate inputs — never trust the client
const parsed = CreatePostSchema.safeParse(raw);
if (!parsed.success) {
return { error: "Invalid input", issues: parsed.error.issues };
}
await db.post.create({
data: parsed.data,
});
revalidatePath("/posts");
return { success: true };
}
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } });
revalidatePath("/posts");
return { success: true };
}
When to use which:
- Inline: Quick, one-off actions tightly coupled to a specific page
- Module-level: Reusable actions shared across multiple components, or actions called from Client Components
Form Handling Without API Routes
Before Server Actions, handling a form mutation in Next.js looked like this:
Client Component → fetch("/api/posts", { method: "POST", body }) → API Route → Database → Response → Update UI
With Server Actions, the flow collapses:
Form action={serverAction} → Server Action → Database → Revalidate → UI Updates
No API route file. No manual fetch. No response parsing. Next.js generates the endpoint, handles the network call, and integrates with React's rendering pipeline.
Full Form Example with Error Handling
// app/contact/page.tsx — Server Component
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
export default function ContactPage() {
async function submitContact(formData: FormData) {
"use server";
const raw = {
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
};
const parsed = ContactSchema.safeParse(raw);
if (!parsed.success) {
// In a real app, you'd return errors to display in the form
throw new Error("Validation failed");
}
await db.contact.create({ data: parsed.data });
// Redirect after successful submission
redirect("/contact/thank-you");
}
return (
<form action={submitContact}>
<label>
Name
<input name="name" type="text" required />
</label>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Message
<textarea name="message" required />
</label>
<button type="submit">Send Message</button>
</form>
);
}
Calling Server Actions from Client Components
This is where module-level Server Actions become essential. Client Components cannot define inline "use server" functions — they must import them from a "use server" module.
// app/actions/todo-actions.ts
"use server";
import { revalidateTag } from "next/cache";
import { db } from "@/lib/db";
export async function toggleTodo(todoId: string, completed: boolean) {
await db.todo.update({
where: { id: todoId },
data: { completed },
});
revalidateTag("todos");
}
export async function deleteTodo(todoId: string) {
await db.todo.delete({ where: { id: todoId } });
revalidateTag("todos");
}
// app/components/TodoItem.tsx
"use client";
import { useTransition } from "react";
import { toggleTodo, deleteTodo } from "@/app/actions/todo-actions";
// Client component imports and calls server actions directly
export default function TodoItem({ id, title, completed }: {
id: string;
title: string;
completed: boolean;
}) {
const [isPending, startTransition] = useTransition();
function handleToggle() {
// useTransition keeps the UI responsive during the server call
startTransition(async () => {
await toggleTodo(id, !completed);
});
}
function handleDelete() {
startTransition(async () => {
await deleteTodo(id);
});
}
return (
<div style={{ opacity: isPending ? 0.5 : 1 }}>
<input
type="checkbox"
checked={completed}
onChange={handleToggle}
disabled={isPending}
/>
<span style={{ textDecoration: completed ? "line-through" : "none" }}>
{title}
</span>
<button onClick={handleDelete} disabled={isPending}>
Delete
</button>
</div>
);
}
Key patterns when calling from Client Components:
- Import the action from a
"use server"module - Wrap the call in
useTransitionto get a pending state without blocking the UI - Use
isPendingto show loading indicators or disable buttons - The action is called like a normal async function — Next.js handles the network
Using useActionState for Form State
React 19 introduced useActionState (formerly useFormState) for managing form submission state in Client Components:
// app/components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribe } from "@/app/actions/newsletter-actions";
// Initial state for the form
const initialState = { message: "", success: false };
export default function NewsletterForm() {
// useActionState wraps the action and manages state + pending
const [state, formAction, isPending] = useActionState(subscribe, initialState);
return (
<form action={formAction}>
<input name="email" type="email" placeholder="your@email.com" required />
<button type="submit" disabled={isPending}>
{isPending ? "Subscribing..." : "Subscribe"}
</button>
{state.message && (
<p style={{ color: state.success ? "green" : "red" }}>
{state.message}
</p>
)}
</form>
);
}
// app/actions/newsletter-actions.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
const EmailSchema = z.string().email();
// When used with useActionState, the action receives previous state as first arg
export async function subscribe(
prevState: { message: string; success: boolean },
formData: FormData
) {
const email = formData.get("email");
const parsed = EmailSchema.safeParse(email);
if (!parsed.success) {
return { message: "Please enter a valid email.", success: false };
}
try {
await db.subscriber.create({ data: { email: parsed.data } });
return { message: "Subscribed successfully!", success: true };
} catch (error) {
return { message: "Something went wrong. Try again.", success: false };
}
}
Progressive Enhancement
One of the most powerful aspects of Server Actions with forms is progressive enhancement. When you pass a Server Action to a <form action={...}>, the form works even if JavaScript hasn't loaded or is disabled.
How it works:
- Without JavaScript: The browser submits the form as a standard HTML POST request. Next.js receives it, runs the Server Action, and returns the updated page. No client-side JavaScript needed.
- With JavaScript: React intercepts the form submission, calls the Server Action via fetch, and updates the UI without a full page reload. You get the SPA experience.
// This form works with AND without JavaScript
export default function SearchPage() {
async function search(formData: FormData) {
"use server";
const query = formData.get("q") as string;
// Process search on the server
redirect(`/search?q=${encodeURIComponent(query)}`);
}
return (
<form action={search}>
<input name="q" placeholder="Search..." />
<button type="submit">Search</button>
</form>
);
}
Why this matters in interviews:
Progressive enhancement is a key differentiator between Server Actions and manual fetch calls. With fetch, if JavaScript fails, nothing works. With Server Actions bound to form action, the HTML platform takes over. This matters for:
- Users on slow connections where JS hasn't loaded yet
- Accessibility tools and screen readers
- Search engine crawlers
- Corporate environments that restrict JavaScript
Revalidation After Mutation
After a Server Action modifies data, you typically need to update what the user sees. Next.js gives you two cache-busting tools:
revalidatePath — Purge by route
"use server";
import { revalidatePath } from "next/cache";
export async function updateProfile(formData: FormData) {
// ... update database ...
// Revalidate a specific page — next request gets fresh data
revalidatePath("/profile");
// Revalidate a layout (affects all child pages)
revalidatePath("/dashboard", "layout");
// Revalidate everything under a dynamic segment
revalidatePath("/blog/[slug]", "page");
}
revalidateTag — Purge by cache tag
// When fetching, tag the cache entry
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
return res.json();
}
// In the Server Action, invalidate by tag
"use server";
import { revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
// ... insert into database ...
// Every fetch tagged with "posts" will be re-fetched
revalidateTag("posts");
}
When to use which:
| Approach | Use When |
|---|---|
revalidatePath | You know exactly which route needs fresh data |
revalidateTag | Multiple routes share the same data source, or you want fine-grained control |
redirect | After mutation, send the user to a different page entirely |
Combining revalidation with redirect:
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function publishArticle(formData: FormData) {
const slug = formData.get("slug") as string;
// ... save to database ...
// Bust the cache for the articles listing
revalidatePath("/articles");
// Bust the cache for this specific article
revalidatePath(`/articles/${slug}`);
// Send the user to the published article
redirect(`/articles/${slug}`);
}
Security Considerations
This is the section interviewers care about most. Server Actions create public HTTP endpoints. Anyone can call them, with any payload.
1. Always Validate Inputs with Zod (or similar)
"use server";
import { z } from "zod";
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["user", "admin"]),
});
export async function updateUser(formData: FormData) {
const raw = {
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
};
// If validation fails, parsed.success is false
const parsed = UpdateUserSchema.safeParse(raw);
if (!parsed.success) {
return { error: "Invalid data", issues: parsed.error.flatten() };
}
// Only use parsed.data — never use raw FormData values directly
await db.user.update({
where: { id: userId },
data: parsed.data,
});
}
2. Always Check Authentication and Authorization
"use server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function deleteComment(commentId: string) {
// Verify the user is logged in
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
// Verify the user owns this comment (authorization)
const comment = await db.comment.findUnique({
where: { id: commentId },
});
if (comment?.authorId !== session.user.id) {
throw new Error("Forbidden — you can only delete your own comments");
}
await db.comment.delete({ where: { id: commentId } });
revalidatePath("/comments");
}
3. Never Trust the Client — Common Attack Vectors
"use server";
// BAD: Trusting a user-provided ID to set their own role
export async function setRole(formData: FormData) {
const userId = formData.get("userId") as string;
const role = formData.get("role") as string;
// Anyone could call this and make themselves admin!
await db.user.update({
where: { id: userId },
data: { role },
});
}
// GOOD: Get userId from the session, validate the role
export async function setRoleSafe(formData: FormData) {
const session = await auth();
if (session?.user?.role !== "admin") {
throw new Error("Only admins can change roles");
}
const targetUserId = formData.get("userId") as string;
const role = z.enum(["user", "moderator"]).parse(formData.get("role"));
await db.user.update({
where: { id: targetUserId },
data: { role },
});
}
4. Rate Limiting
Server Actions are HTTP endpoints. Without rate limiting, they're vulnerable to abuse:
"use server";
import { headers } from "next/headers";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "60 s"), // 10 requests per minute
});
export async function submitFeedback(formData: FormData) {
const headersList = await headers();
const ip = headersList.get("x-forwarded-for") ?? "unknown";
const { success } = await ratelimit.limit(ip);
if (!success) {
return { error: "Too many requests. Please try again later." };
}
// ... process the feedback ...
}
Common Mistakes
Mistake 1: Defining "use server" functions inside Client Components
You cannot put "use server" inside a "use client" file. Client Components must import Server Actions from a separate "use server" module file. This is a hard rule — Next.js will throw a build error.
Mistake 2: Skipping server-side validation because the form has required attributes
HTML required and client-side validation are UX conveniences. They are trivially bypassed with browser dev tools or a direct HTTP request. Every Server Action must validate its inputs on the server as if the request could contain anything — because it can.
Mistake 3: Not checking authorization inside the action
Authentication (who is this user?) is step one. Authorization (can this user do this specific thing?) is step two, and it's the one developers forget. Always verify that the authenticated user has permission to perform the specific operation on the specific resource.
Mistake 4: Forgetting to revalidate after mutation
You update the database but the user still sees stale data. Without revalidatePath or revalidateTag, Next.js serves the cached version. Every mutation should answer the question: "Which cached pages or data need to be refreshed now?"
Mistake 5: Using Server Actions for data fetching
Server Actions are designed for mutations (create, update, delete). For reading data, use Server Components with async/await or the fetch API with caching. Using Server Actions for GET-like operations misses out on caching, deduplication, and static optimization.
Interview Questions
Q1: What is a Server Action in Next.js, and how does it differ from an API route?
A Server Action is an async function marked with
"use server"that runs exclusively on the server but can be invoked from the client. Next.js automatically creates an HTTP endpoint for it behind the scenes. Unlike API routes, you don't manually define the endpoint, write request/response handling, or callfetchfrom the client. You pass the function directly to a formactionor call it like a normal function in a Client Component. The key advantage is type safety and colocation — the action lives close to the UI that uses it, and TypeScript enforces the argument and return types end-to-end.
Q2: What is the difference between inline Server Actions and module-level Server Actions?
Inline Server Actions have
"use server"inside the function body and are defined directly within a Server Component. They're convenient for one-off actions tied to a specific page. Module-level Server Actions are in files where"use server"is the first line, making every exported function a Server Action. These can be imported by Client Components and shared across multiple pages. Client Components can only use module-level actions since you cannot define"use server"functions inside a"use client"file.
Q3: How does progressive enhancement work with Server Actions?
When a Server Action is passed to a
<form action={...}>, the form works even if JavaScript is disabled or hasn't loaded yet. Without JS, the browser submits the form as a standard POST request, Next.js runs the action, and returns the updated page with a full navigation. With JS enabled, React intercepts the submission, calls the action via fetch, and updates the UI without a page reload. This dual behavior means the core functionality is always available, with JavaScript providing an enhanced experience — the definition of progressive enhancement.
Q4: How do you handle cache invalidation after a Server Action mutates data?
Next.js provides two functions:
revalidatePath("/some-route")purges the cache for a specific route or layout, andrevalidateTag("tag-name")purges allfetchcalls tagged with that name. You call these inside your Server Action after the database write. For navigation after mutation, you useredirect()fromnext/navigation. A common pattern is to revalidate the listing page's cache and then redirect to the newly created resource. Without revalidation, users see stale cached data even after successful mutations.
Q5: What are the security risks of Server Actions, and how do you mitigate them?
Server Actions create public HTTP endpoints — anyone can call them with arbitrary payloads, even without your UI. The main risks are: (1) missing input validation, letting malformed or malicious data hit your database — mitigate with Zod or similar schema validation on every action; (2) missing authentication, letting unauthenticated users trigger mutations — always check the session; (3) missing authorization, letting authenticated users act on resources they don't own — verify ownership or role before every operation; (4) no rate limiting, enabling brute-force or spam attacks — add rate limiting with tools like Upstash. The mental model is: treat every Server Action as a public API endpoint that hostile clients will call directly.
Quick Reference — Cheat Sheet
+------------------------------------+----------------------------------------------+
| Concept | Key Point |
+------------------------------------+----------------------------------------------+
| "use server" (inline) | Inside a function body in a Server |
| | Component. One-off, page-specific actions. |
+------------------------------------+----------------------------------------------+
| "use server" (module-level) | Top of file. Every export becomes a Server |
| | Action. Required for Client Component use. |
+------------------------------------+----------------------------------------------+
| form action={serverAction} | Pass directly to form. Works with and |
| | without JavaScript (progressive enhancement).|
+------------------------------------+----------------------------------------------+
| useTransition + action | Call actions from Client Components with |
| | isPending state for loading indicators. |
+------------------------------------+----------------------------------------------+
| useActionState | Manages form state + pending in Client |
| | Components. Action receives prevState. |
+------------------------------------+----------------------------------------------+
| revalidatePath("/route") | Purge cache for a specific page or layout. |
| | Call after database mutation. |
+------------------------------------+----------------------------------------------+
| revalidateTag("tag") | Purge all fetch calls with that cache tag. |
| | Fine-grained, cross-route invalidation. |
+------------------------------------+----------------------------------------------+
| redirect("/path") | Navigate after mutation. Must be called |
| | outside try/catch (it throws internally). |
+------------------------------------+----------------------------------------------+
| Zod validation | Always validate FormData on the server. |
| | HTML required attributes are not security. |
+------------------------------------+----------------------------------------------+
| Auth + Authorization | Check session exists AND user has permission |
| | for the specific resource/operation. |
+------------------------------------+----------------------------------------------+
RULE: Server Actions are for mutations. Use Server Components for data fetching.
RULE: Every action is a public endpoint. Validate, authenticate, authorize.
RULE: Always revalidate after mutation — or users see stale data.
Previous: Lesson 3.3 — Server vs Client Decision Framework → Next: Lesson 4.1 — Fetching in Server Components →
This is Lesson 3.4 of the Next.js Interview Prep Course — 8 chapters, 33 lessons.