"Stop building API routes for every mutation. Server Actions in Next.js 16 change the game. Here's why it's not just a new feature—it's a fundamentally different approach to building interactive apps."
The Problem We've Been Living With
For years, we've been doing the same thing. Want to update data? Build an API route. Want to submit a form? Build an API route. Want to delete something? Build an API route.
// The old way - API Route
// app/api/posts/route.ts
export async function POST(request: Request) {
const data = await request.json()
const session = await getSession()
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const post = await db.posts.create({ data })
return Response.json(post)
}
// Client component
'use client'
export function CreatePostForm() {
async function handleSubmit(e: FormEvent) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData)),
})
if (!res.ok) {
alert('Error!')
}
}
return <form onSubmit={handleSubmit}>...</form>
}
See all that code? Separate API route, client-side fetching, manual error handling, no progressive enhancement. If JavaScript fails? The form doesn't work.
Then Next.js 16 introduced Server Actions. And suddenly, all of that became unnecessary.
The New Way: Server Actions
Look at the same example with Server Actions:
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const session = await getSession();
if (!session) {
throw new Error("Unauthorized");
}
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db.posts.create({
data: { title, content, userId: session.user.id },
});
revalidatePath("/posts");
}
And the form:
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</form>
)
}
That's it. No API route. No client-side fetching. No complex error handling. Just a simple form that works even without JavaScript.
That's the power of Server Actions.
What Are Server Actions?
A Server Action is an async function that runs exclusively on the server. It's a special type of Server Function designed specifically for mutations and form submissions.
Server Actions:
- Execute on the server only (never exposed to the client)
- Use POST method exclusively
- Can be called directly from Client Components
- Support progressive enhancement (work without JavaScript)
- Return updated UI and data in a single roundtrip
Creating Server Actions
Method 1: Separate File (Recommended)
Create a dedicated file with the 'use server' directive at the top:
// app/actions/posts.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
// Define validation schema
const PostSchema = z.object({
title: z
.string()
.min(3, { message: "Title must be at least 3 characters" })
.trim(),
content: z
.string()
.min(10, { message: "Content must be at least 10 characters" })
.trim(),
});
export async function createPost(formData: FormData) {
// 1. Verify authentication
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
// 2. Validate data
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: "Validation failed. Check your inputs.",
};
}
// 3. Perform mutation
const post = await db.posts.create({
data: {
...validatedFields.data,
userId: session.user.id,
},
});
// 4. Invalidate cache
revalidatePath("/posts");
// 5. Redirect to new post
redirect(`/posts/${post.id}`);
}
export async function updatePost(id: string, formData: FormData) {
"use server";
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
// Verify ownership
const post = await db.posts.findUnique({ where: { id } });
if (post.userId !== session.user.id) {
throw new Error("Forbidden");
}
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
await db.posts.update({
where: { id },
data: validatedFields.data,
});
revalidatePath(`/posts/${id}`);
revalidatePath("/posts");
}
export async function deletePost(id: string) {
"use server";
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
const post = await db.posts.findUnique({ where: { id } });
if (post.userId !== session.user.id) {
throw new Error("Forbidden");
}
await db.posts.delete({ where: { id } });
revalidatePath("/posts");
redirect("/posts");
}
Method 2: Inline in Server Components
You can define Server Actions inside Server Components:
// app/posts/new/page.tsx
export default function NewPostPage() {
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title') as string
await db.posts.create({ data: { title } })
revalidatePath('/posts')
}
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">Create</button>
</form>
)
}
Pro Tip!
Important: You cannot define Server Actions inside Client Components. You must import them from a separate file with 'use server'.
Invoking Server Actions
1. Forms (Recommended)
The most common and recommended way:
// app/ui/create-post-form.tsx
import { createPost } from '@/app/actions/posts'
export function CreatePostForm() {
return (
<form action={createPost}>
<input type="text" name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
)
}
The Server Action automatically receives FormData:
export async function createPost(formData: FormData) {
const title = formData.get("title"); // Extract data
const content = formData.get("content");
// Process data...
}
2. Event Handlers in Client Components
You can call Server Actions from event handlers:
// app/ui/like-button.tsx
'use client'
import { incrementLike } from '@/app/actions/posts'
import { useState } from 'react'
export default function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes)
return (
<button
onClick={async () => {
const newLikes = await incrementLike(postId)
setLikes(newLikes)
}}
>
Like ({likes})
</button>
)
}
3. Passing as Props
You can pass a Server Action as a prop to a Client Component:
// app/page.tsx (Server Component)
import { updateItem } from '@/app/actions'
import ClientForm from './client-form'
export default function Page() {
return <ClientForm updateAction={updateItem} />
}
// app/client-form.tsx
'use client'
export default function ClientForm({ updateAction }) {
return (
<form action={updateAction}>
<input name="item" />
<button type="submit">Update</button>
</form>
)
}
Using bind to Pass Additional Arguments
When you need to pass additional arguments like IDs, use .bind():
// app/posts/[id]/edit/page.tsx
import { updatePost } from '@/app/actions/posts'
import EditForm from './edit-form'
export default async function EditPage({ params }) {
const { id } = await params
const post = await getPost(id)
return <EditForm post={post} />
}
// app/posts/[id]/edit/edit-form.tsx
'use client'
import { useActionState } from 'react'
import { updatePost } from '@/app/actions/posts'
export default function EditForm({ post }) {
// Use bind to pass the ID
const updatePostWithId = updatePost.bind(null, post.id)
const [state, formAction] = useActionState(updatePostWithId, null)
return (
<form action={formAction}>
<input
name="title"
defaultValue={post.title}
required
/>
<textarea
name="content"
defaultValue={post.content}
required
/>
{state?.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
<button type="submit">Update</button>
</form>
)
}
The Server Action receives the ID as the first argument:
// app/actions/posts.ts
"use server";
export async function updatePost(
id: string, // From bind
formData: FormData, // From form
) {
// Use ID and data...
}
Showing Pending State
Use useActionState to show a loading indicator:
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions/posts'
export function CreateButton() {
const [state, action, pending] = useActionState(createPost, null)
return (
<button onClick={() => action()} disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
)
}
Or use useFormStatus for a separate button:
// app/ui/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
)
}
Validating Data with Zod
Use Zod to validate form data on the server:
// app/lib/validations.ts
import { z } from "zod";
export const PostSchema = z.object({
title: z
.string()
.min(3, { message: "Title must be at least 3 characters" })
.max(100, { message: "Title must not exceed 100 characters" })
.trim(),
content: z
.string()
.min(10, { message: "Content must be at least 10 characters" })
.trim(),
published: z.boolean().default(false),
});
export type FormState = {
errors?: {
title?: string[];
content?: string[];
published?: string[];
};
message?: string;
success?: boolean;
};
// app/actions/posts.ts
"use server";
import { PostSchema, FormState } from "@/lib/validations";
export async function createPost(
state: FormState,
formData: FormData,
): Promise<FormState> {
// Validate data
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "true",
});
// Return early if validation fails
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: "Validation failed. Check your inputs.",
success: false,
};
}
// Use validated data
const { title, content, published } = validatedFields.data;
try {
await db.posts.create({
data: { title, content, published },
});
revalidatePath("/posts");
return { success: true, message: "Post created successfully" };
} catch (error) {
return {
success: false,
message: "Database error",
};
}
}
Pro Tip!
Good to know: Use safeParse() instead of parse() in Server Actions. safeParse() returns a result object instead of throwing errors.
Revalidating Data
After updating data, you need to update the cache:
revalidatePath - Invalidate Specific Pages
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
await db.posts.create({
data: {
/* ... */
},
});
// Invalidate the list page
revalidatePath("/posts");
// Invalidate all matching pages
revalidatePath("/blog/[slug]", "page");
}
revalidateTag - Invalidate by Tag
"use server";
import { revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
await db.posts.create({
data: {
/* ... */
},
});
// Invalidate all data tagged with 'posts'
revalidateTag("posts", "max");
}
Tag your data:
// Fetch with cache tag
const posts = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
refresh - Refresh Current Page
"use server";
import { refresh } from "next/cache";
export async function updatePost(formData: FormData) {
await db.posts.update({
/* ... */
});
// Refresh the current page
refresh();
}
Redirecting After Mutations
Redirect the user to a different page after mutation:
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: {
/* ... */
},
});
// Revalidate before redirecting
revalidatePath("/posts");
// Redirect to the new post
redirect(`/posts/${post.id}`);
}
Pro Tip!
Important: redirect() throws an exception. Call revalidatePath() or revalidateTag() before redirect().
Working with Cookies
Server Actions can read, write, and delete cookies:
"use server";
import { cookies } from "next/headers";
export async function updatePreferences(formData: FormData) {
const cookieStore = await cookies();
// Read cookie
const theme = cookieStore.get("theme")?.value;
// Write cookie
cookieStore.set("theme", "dark");
// Delete cookie
cookieStore.delete("old-preference");
}
When you write or delete a cookie in a Server Action, Next.js automatically re-renders the page to reflect the new value.
Security Best Practices
Always Verify Permissions
"use server";
export async function deletePost(id: string) {
// 1. Verify authentication
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
// 2. Verify ownership
const post = await db.posts.findUnique({ where: { id } });
if (post.userId !== session.user.id) {
throw new Error("Forbidden");
}
// 3. Perform mutation
await db.posts.delete({ where: { id } });
}
Pro Tip!
Critical: Server Actions are reachable via direct POST requests. Always verify authentication and authorization inside every Server Action.
Validate Input
Always validate user input:
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string().email({ message: "Invalid email" }),
age: z.number().min(18).max(120),
});
export async function updateProfile(formData: FormData) {
const result = schema.safeParse({
email: formData.get("email"),
age: Number(formData.get("age")),
});
if (!result.success) {
return { error: "Invalid input" };
}
// Safe to use
const { email, age } = result.data;
}
When to Use Server Actions vs Route Handlers
| Use Server Actions When | Use Route Handlers When |
|---|---|
| Handling form submissions | Building REST APIs for external clients |
| Performing mutations from your UI | Handling webhooks from third parties |
| Need progressive enhancement | Need GET endpoints with HTTP caching |
| Want end-to-end type safety | Need OpenAPI/Swagger documentation |
| Internal application logic | Supporting mobile apps |
Complete Example: Blog Post CRUD System
Here's a complete example that ties everything together:
// lib/validations.ts
import { z } from "zod";
export const PostSchema = z.object({
title: z.string().min(3).max(100).trim(),
content: z.string().min(10).trim(),
published: z.boolean().default(false),
});
export type FormState = {
errors?: {
title?: string[];
content?: string[];
};
message?: string;
success?: boolean;
};
// app/actions/posts.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { PostSchema, FormState } from "@/lib/validations";
// CREATE
export async function createPost(
state: FormState,
formData: FormData,
): Promise<FormState> {
const session = await getSession();
if (!session?.user) {
return { message: "Unauthorized", success: false };
}
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "true",
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: "Validation failed",
success: false,
};
}
try {
const post = await db.posts.create({
data: {
...validatedFields.data,
userId: session.user.id,
},
});
revalidatePath("/posts");
redirect(`/posts/${post.id}`);
} catch (error) {
return { message: "Database error", success: false };
}
}
// UPDATE
export async function updatePost(
id: string,
state: FormState,
formData: FormData,
): Promise<FormState> {
const session = await getSession();
if (!session?.user) {
return { message: "Unauthorized", success: false };
}
const post = await db.posts.findUnique({ where: { id } });
if (!post || post.userId !== session.user.id) {
return { message: "Forbidden", success: false };
}
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "true",
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
success: false,
};
}
try {
await db.posts.update({
where: { id },
data: validatedFields.data,
});
revalidatePath(`/posts/${id}`);
revalidatePath("/posts");
return { message: "Updated successfully", success: true };
} catch (error) {
return { message: "Database error", success: false };
}
}
// DELETE
export async function deletePost(id: string) {
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
const post = await db.posts.findUnique({ where: { id } });
if (!post || post.userId !== session.user.id) {
throw new Error("Forbidden");
}
await db.posts.delete({ where: { id } });
revalidatePath("/posts");
redirect("/posts");
}
// app/posts/new/page.tsx
import { CreatePostForm } from '@/app/ui/create-post-form'
export default function NewPostPage() {
return (
<div>
<h1>Create New Post</h1>
<CreatePostForm />
</div>
)
}
// app/ui/create-post-form.tsx
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions/posts'
import { SubmitButton } from './submit-button'
export function CreatePostForm() {
const [state, formAction] = useActionState(createPost, null)
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
required
/>
{state?.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
rows={10}
required
/>
{state?.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
</div>
<div>
<label>
<input type="checkbox" name="published" value="true" />
Publish immediately
</label>
</div>
{state?.message && !state.success && (
<p className="error">{state.message}</p>
)}
<SubmitButton />
</form>
)
}
// app/ui/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Processing...' : 'Submit'}
</button>
)
}
The Bottom Line
Server Actions in Next.js 16 provide a powerful, type-safe way to handle mutations and form submissions. Key takeaways:
- Use
'use server'directive to create Server Actions - Prefer forms for progressive enhancement
- Always validate input with Zod's
safeParse() - Use
.bind()to pass additional arguments like IDs - Always verify authentication and authorization
- Use
revalidatePath()orrevalidateTag()to update cached data - Handle errors gracefully and provide user feedback
- Choose Server Actions for internal mutations, Route Handlers for external APIs
Server Actions simplify data mutations while maintaining security, performance, and user experience.
Sources:
Happy Coding! — Ahmed Fahmy