NextJS DOC
Part 6 of 15Next.js Data Mutation: Complete Server Actions Guide
Next.js Data Mutation: Complete Server Actions Guide
Introduction: The End of API Routes for Mutations
Server Actions represent a paradigm shift in how we handle data mutations in React applications. Instead of creating API routes, writing fetch calls, and managing client-side state, you write async functions that run on the server and call them directly from your components.
The result: type-safe, progressively enhanced forms with automatic revalidation—all without the boilerplate of traditional REST endpoints.
Server Actions Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ SERVER ACTIONS FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ CLIENT SERVER │
│ ────── ────── │
│ │
│ ┌─────────────────┐ │
│ │ <form> │ │
│ │ action={fn} │ ──── POST Request ────► ┌──────────────────┐ │
│ │ </form> │ (FormData) │ Server Action │ │
│ └─────────────────┘ │ ────────────── │ │
│ │ │ 'use server' │ │
│ │ │ │ │
│ │ │ 1. Auth check │ │
│ │ │ 2. Validate │ │
│ │ │ 3. Mutate DB │ │
│ │ │ 4. Revalidate │ │
│ │ │ 5. Redirect? │ │
│ │ └────────┬─────────┘ │
│ │ │ │
│ │ ◄── Response ───────┘ │
│ │ (Updated UI + Data) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Updated UI │ ◄── Single roundtrip, no client state mgmt │
│ │ (revalidated) │ │
│ └─────────────────┘ │
│ │
│ KEY BENEFITS: │
│ • Progressive enhancement (works without JS) │
│ • Type-safe end-to-end │
│ • Automatic request deduplication │
│ • Built-in pending states │
│ • Integrated cache revalidation │
│ │
└─────────────────────────────────────────────────────────────────────┘
Creating Server Functions
The 'use server' Directive
Mark functions or files as server-only:
// Option 1: File-level directive (recommended for action files)
// app/actions.ts
'use server';
// ALL exports are Server Actions
export async function createPost(formData: FormData) {
// Runs on server
}
export async function deletePost(id: string) {
// Runs on server
}
export async function updatePost(id: string, formData: FormData) {
// Runs on server
}
// Option 2: Function-level directive (for inline actions)
// app/posts/page.tsx
export default function PostsPage() {
async function createPost(formData: FormData) {
'use server';
// This specific function runs on server
}
return <form action={createPost}>{/* ... */}</form>;
}
Complete Server Action Structure
// app/actions/posts.ts
'use server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
// Validation schema
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(10, 'Content must be at least 10 characters'),
published: z.boolean().default(false),
});
// Return type for form state
type ActionState = {
success: boolean;
message: string;
errors?: {
title?: string[];
content?: string[];
};
};
export async function createPost(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
// 1. Authentication
const session = await auth();
if (!session?.user) {
return {
success: false,
message: 'You must be logged in to create a post',
};
}
// 2. Validation
const validatedFields = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'true',
});
if (!validatedFields.success) {
return {
success: false,
message: 'Invalid fields',
errors: validatedFields.error.flatten().fieldErrors,
};
}
// 3. Mutation
try {
const post = await db.post.create({
data: {
...validatedFields.data,
authorId: session.user.id,
},
});
// 4. Revalidation
revalidatePath('/posts');
revalidatePath(`/users/${session.user.id}`);
// 5. Success response (or redirect)
return {
success: true,
message: 'Post created successfully',
};
} catch (error) {
return {
success: false,
message: 'Database error: Failed to create post',
};
}
}
export async function deletePost(id: string): Promise<ActionState> {
const session = await auth();
if (!session?.user) {
return { success: false, message: 'Unauthorized' };
}
// Verify ownership
const post = await db.post.findUnique({
where: { id },
select: { authorId: true },
});
if (!post) {
return { success: false, message: 'Post not found' };
}
if (post.authorId !== session.user.id) {
return { success: false, message: 'You can only delete your own posts' };
}
try {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
return { success: true, message: 'Post deleted' };
} catch (error) {
return { success: false, message: 'Failed to delete post' };
}
}
Invoking Server Actions
Method 1: Form Action (Recommended)
// app/posts/new/page.tsx
import { createPost } from '@/app/actions/posts';
export default function NewPostPage() {
return (
<form action={createPost}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
type="text"
required
/>
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
required
/>
</div>
<div>
<label>
<input type="checkbox" name="published" value="true" />
Publish immediately
</label>
</div>
<button type="submit">Create Post</button>
</form>
);
}
Method 2: Form with useActionState (With Loading/Error States)
// app/posts/new/form.tsx
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';
const initialState = {
success: false,
message: '',
errors: undefined,
};
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
initialState
);
return (
<form action={formAction}>
{/* Global error message */}
{state.message && !state.success && (
<div className="error-banner" role="alert">
{state.message}
</div>
)}
{/* Success message */}
{state.success && (
<div className="success-banner" role="status">
{state.message}
</div>
)}
<div>
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
type="text"
aria-describedby="title-error"
disabled={isPending}
/>
{state.errors?.title && (
<p id="title-error" className="error">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
aria-describedby="content-error"
disabled={isPending}
/>
{state.errors?.content && (
<p id="content-error" className="error">
{state.errors.content[0]}
</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Method 3: Button with formAction
// Multiple actions in one form
'use client';
import { saveAsDraft, publish } from '@/app/actions/posts';
export function PostForm() {
return (
<form>
<input name="title" type="text" />
<textarea name="content" />
{/* Different buttons trigger different actions */}
<button type="submit" formAction={saveAsDraft}>
Save as Draft
</button>
<button type="submit" formAction={publish}>
Publish
</button>
</form>
);
}
Method 4: Event Handler (onClick)
// app/posts/[id]/like-button.tsx
'use client';
import { useState, useTransition } from 'react';
import { likePost } from '@/app/actions/posts';
interface LikeButtonProps {
postId: string;
initialLikes: number;
initialLiked: boolean;
}
export function LikeButton({
postId,
initialLikes,
initialLiked,
}: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(initialLiked);
const [isPending, startTransition] = useTransition();
async function handleClick() {
startTransition(async () => {
const result = await likePost(postId);
if (result.success) {
setLikes(result.likes);
setLiked(result.liked);
}
});
}
return (
<button
onClick={handleClick}
disabled={isPending}
className={liked ? 'liked' : ''}
>
{isPending ? '...' : liked ? '❤️' : '🤍'} {likes}
</button>
);
}
Method 5: useEffect (Auto-trigger)
// app/posts/[id]/view-counter.tsx
'use client';
import { useEffect, useTransition } from 'react';
import { incrementViews } from '@/app/actions/analytics';
export function ViewCounter({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
useEffect(() => {
// Increment view count on mount
startTransition(async () => {
await incrementViews(postId);
});
}, [postId]);
return null; // Or show view count
}
Optimistic Updates with useOptimistic
Show immediate feedback while the server processes:
// app/posts/[id]/comments.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { addComment } from '@/app/actions/comments';
interface Comment {
id: string;
text: string;
author: string;
createdAt: Date;
pending?: boolean;
}
interface CommentsProps {
postId: string;
initialComments: Comment[];
}
export function Comments({ postId, initialComments }: CommentsProps) {
const [isPending, startTransition] = useTransition();
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state: Comment[], newComment: Comment) => [...state, newComment]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
// Optimistic update - show immediately
const optimisticComment: Comment = {
id: `temp-${Date.now()}`,
text,
author: 'You',
createdAt: new Date(),
pending: true,
};
startTransition(async () => {
addOptimisticComment(optimisticComment);
await addComment(postId, formData);
});
}
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li
key={comment.id}
className={comment.pending ? 'opacity-50' : ''}
>
<p>{comment.text}</p>
<small>
{comment.author} • {comment.pending ? 'Posting...' : formatDate(comment.createdAt)}
</small>
</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" placeholder="Add a comment..." />
<button type="submit" disabled={isPending}>
Post
</button>
</form>
</div>
);
}
Advanced Optimistic Pattern: Todo List
// app/todos/todo-list.tsx
'use client';
import { useOptimistic, useTransition, useRef } from 'react';
import { addTodo, toggleTodo, deleteTodo } from '@/app/actions/todos';
interface Todo {
id: string;
text: string;
completed: boolean;
pending?: 'adding' | 'toggling' | 'deleting';
}
type OptimisticAction =
| { type: 'add'; todo: Todo }
| { type: 'toggle'; id: string }
| { type: 'delete'; id: string };
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [isPending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
const [optimisticTodos, dispatch] = useOptimistic(
initialTodos,
(state: Todo[], action: OptimisticAction) => {
switch (action.type) {
case 'add':
return [...state, action.todo];
case 'toggle':
return state.map((todo) =>
todo.id === action.id
? { ...todo, completed: !todo.completed, pending: 'toggling' }
: todo
);
case 'delete':
return state.map((todo) =>
todo.id === action.id
? { ...todo, pending: 'deleting' }
: todo
);
default:
return state;
}
}
);
async function handleAdd(formData: FormData) {
const text = formData.get('text') as string;
if (!text.trim()) return;
const tempId = `temp-${Date.now()}`;
startTransition(async () => {
dispatch({
type: 'add',
todo: { id: tempId, text, completed: false, pending: 'adding' },
});
formRef.current?.reset();
await addTodo(formData);
});
}
async function handleToggle(id: string) {
startTransition(async () => {
dispatch({ type: 'toggle', id });
await toggleTodo(id);
});
}
async function handleDelete(id: string) {
startTransition(async () => {
dispatch({ type: 'delete', id });
await deleteTodo(id);
});
}
return (
<div>
<form ref={formRef} action={handleAdd}>
<input name="text" placeholder="Add a todo..." />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos
.filter((todo) => todo.pending !== 'deleting')
.map((todo) => (
<li
key={todo.id}
className={todo.pending ? 'opacity-50' : ''}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
disabled={!!todo.pending}
/>
<span className={todo.completed ? 'line-through' : ''}>
{todo.text}
</span>
<button
onClick={() => handleDelete(todo.id)}
disabled={!!todo.pending}
>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
Cache Revalidation
revalidatePath - Revalidate by Route
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
// ... create post
// Revalidate specific path
revalidatePath('/posts');
// Revalidate with specific type
revalidatePath('/posts', 'page'); // Only the page
revalidatePath('/posts', 'layout'); // Layout and all pages below
// Revalidate dynamic route
revalidatePath(`/posts/${postId}`);
// Revalidate all paths (use sparingly)
revalidatePath('/', 'layout');
}
revalidateTag - Revalidate by Tag
// lib/data.ts
export async function getPosts() {
const response = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }, // Tag the cache
});
return response.json();
}
export async function getPost(id: string) {
const response = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ['posts', `post-${id}`] }, // Multiple tags
});
return response.json();
}
// app/actions/posts.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
// ... create post
// Revalidate all caches tagged with 'posts'
revalidateTag('posts');
}
export async function updatePost(id: string, formData: FormData) {
// ... update post
// Revalidate specific post and list
revalidateTag(`post-${id}`);
revalidateTag('posts');
}
refresh() - Refresh Current Route
'use server';
import { refresh } from 'next/cache';
export async function updateUserPreferences(formData: FormData) {
// ... update preferences
// Refresh current page (re-runs Server Components)
refresh();
}
Redirecting After Mutation
'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const session = await auth();
if (!session) {
redirect('/login'); // Redirect unauthenticated users
}
// ... create post
revalidatePath('/posts');
// IMPORTANT: redirect() throws, code after won't execute
redirect(`/posts/${post.id}`);
// This never runs!
console.log('after redirect');
}
// Conditional redirect
export async function submitForm(formData: FormData) {
const result = await processForm(formData);
if (result.needsVerification) {
redirect('/verify-email');
}
if (result.isNewUser) {
redirect('/onboarding');
}
revalidatePath('/dashboard');
redirect('/dashboard');
}
Cookie Manipulation
'use server';
import { cookies } from 'next/headers';
export async function setTheme(theme: 'light' | 'dark') {
const cookieStore = await cookies();
cookieStore.set('theme', theme, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
});
// UI automatically re-renders with new cookie value
}
export async function logout() {
const cookieStore = await cookies();
// Delete session cookie
cookieStore.delete('session');
// Or set with past expiry
cookieStore.set('session', '', {
expires: new Date(0),
});
}
export async function getPreferences() {
const cookieStore = await cookies();
const theme = cookieStore.get('theme')?.value ?? 'light';
const locale = cookieStore.get('locale')?.value ?? 'en';
return { theme, locale };
}
Error Handling Patterns
Returning Errors (Recommended)
'use server';
type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string };
export async function createPost(
formData: FormData
): Promise<ActionResult<{ id: string }>> {
try {
const session = await auth();
if (!session) {
return { success: false, error: 'Unauthorized' };
}
const validated = schema.safeParse(Object.fromEntries(formData));
if (!validated.success) {
return { success: false, error: 'Validation failed' };
}
const post = await db.post.create({ data: validated.data });
revalidatePath('/posts');
return { success: true, data: { id: post.id } };
} catch (error) {
console.error('Create post error:', error);
return { success: false, error: 'Something went wrong' };
}
}
Throwing Errors (For Error Boundaries)
'use server';
export async function deletePost(id: string) {
const session = await auth();
if (!session) {
throw new Error('Unauthorized'); // Caught by error.tsx
}
const post = await db.post.findUnique({ where: { id } });
if (!post) {
throw new Error('Post not found');
}
if (post.authorId !== session.user.id) {
throw new Error('Forbidden');
}
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}
Custom Error Classes
// lib/errors.ts
export class ActionError extends Error {
constructor(
message: string,
public code: string,
public status: number = 400
) {
super(message);
this.name = 'ActionError';
}
}
export class UnauthorizedError extends ActionError {
constructor(message = 'Unauthorized') {
super(message, 'UNAUTHORIZED', 401);
}
}
export class ForbiddenError extends ActionError {
constructor(message = 'Forbidden') {
super(message, 'FORBIDDEN', 403);
}
}
export class NotFoundError extends ActionError {
constructor(message = 'Not found') {
super(message, 'NOT_FOUND', 404);
}
}
// app/actions/posts.ts
'use server';
import { UnauthorizedError, ForbiddenError, NotFoundError } from '@/lib/errors';
export async function deletePost(id: string) {
const session = await auth();
if (!session) {
throw new UnauthorizedError();
}
const post = await db.post.findUnique({ where: { id } });
if (!post) {
throw new NotFoundError('Post not found');
}
if (post.authorId !== session.user.id) {
throw new ForbiddenError('You can only delete your own posts');
}
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}
Security Best Practices
Always Authenticate and Authorize
'use server';
// ❌ BAD: No auth check
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
}
// ✅ GOOD: Auth + ownership verification
export async function deletePost(id: string) {
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized');
}
const post = await db.post.findUnique({
where: { id },
select: { authorId: true },
});
if (!post) {
throw new Error('Post not found');
}
// Verify ownership OR admin role
if (post.authorId !== session.user.id && session.user.role !== 'admin') {
throw new Error('Forbidden');
}
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}
Validate All Inputs
'use server';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().min(1).max(50000),
categoryId: z.string().uuid(),
});
export async function createPost(formData: FormData) {
// Never trust client input
const rawInput = {
title: formData.get('title'),
content: formData.get('content'),
categoryId: formData.get('categoryId'),
};
const validated = CreatePostSchema.safeParse(rawInput);
if (!validated.success) {
return {
success: false,
errors: validated.error.flatten().fieldErrors,
};
}
// Use validated.data, not rawInput
await db.post.create({ data: validated.data });
}
Rate Limiting
'use server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { headers } from 'next/headers';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
});
export async function createComment(formData: FormData) {
const session = await auth();
if (!session) {
throw new Error('Unauthorized');
}
// Rate limit by user ID or IP
const identifier = session.user.id;
const { success, limit, reset, remaining } = await ratelimit.limit(identifier);
if (!success) {
return {
success: false,
error: `Rate limited. Try again in ${Math.ceil((reset - Date.now()) / 1000)} seconds`,
};
}
// Proceed with action...
}
Closure Security
// ❌ DANGEROUS: Sensitive data in closure
export default async function Page() {
const user = await getUser();
async function updateProfile(formData: FormData) {
'use server';
// user.secretToken is captured in closure and could be exposed
await db.user.update({
where: { id: user.id },
data: { token: user.secretToken }, // DON'T DO THIS
});
}
return <form action={updateProfile}>...</form>;
}
// ✅ SAFE: Re-fetch in action
export default async function Page() {
const user = await getUser();
async function updateProfile(formData: FormData) {
'use server';
// Re-authenticate inside the action
const session = await auth();
if (!session) throw new Error('Unauthorized');
const freshUser = await getUser(session.user.id);
// Use fresh data
}
return <form action={updateProfile}>...</form>;
}
Progressive Enhancement
Server Actions work without JavaScript:
// This form works even if JS fails to load or is disabled
export default function SignupForm() {
return (
<form action={signup}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign Up</button>
</form>
);
}
// The action handles everything server-side
'use server';
export async function signup(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
// Validate
// Create user
// Set session cookie
redirect('/dashboard');
}
Behavior by JS state:
| JavaScript Status | Form Behavior |
|---|---|
| Not loaded yet | Form queued, submitted after hydration |
| Disabled | Full page reload, server handles response |
| Loaded | Client-side transition, no full reload |
Passing Additional Arguments
Using .bind()
// app/posts/[id]/page.tsx
import { deletePost } from '@/app/actions/posts';
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
// Bind the post ID to the action
const deletePostWithId = deletePost.bind(null, id);
return (
<div>
<h1>{post.title}</h1>
<form action={deletePostWithId}>
<button type="submit">Delete</button>
</form>
</div>
);
}
// app/actions/posts.ts
'use server';
export async function deletePost(id: string, formData: FormData) {
// id is bound, formData is from form submission
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}
Using Hidden Inputs
// Alternative: hidden form fields
export default function PostActions({ postId }: { postId: string }) {
return (
<form action={deletePost}>
<input type="hidden" name="postId" value={postId} />
<button type="submit">Delete</button>
</form>
);
}
// Action extracts from formData
'use server';
export async function deletePost(formData: FormData) {
const postId = formData.get('postId') as string;
// ...
}
Key Takeaways
-
Server Actions replace API routes for mutations: No more
/api/postsendpoints—call functions directly from components. -
Always authenticate and authorize: Server Actions are public POST endpoints. Verify the user can perform the action.
-
Validate all inputs: Use Zod or similar. Never trust FormData directly.
-
useActionStatefor form state: Get pending status and return values from actions. -
useOptimisticfor instant feedback: Update UI immediately, sync with server in background. -
Revalidate after mutations: Use
revalidatePathorrevalidateTagto refresh cached data. -
redirect()throws: Place it afterrevalidatePath. Code after redirect won't execute. -
Progressive enhancement built-in: Forms work without JavaScript—no extra code needed.
-
Return errors, don't always throw: Return structured errors for form validation; throw for error boundaries.
-
Bind arguments with
.bind(): Pass additional data to actions beyond FormData.
What did you think?
Related Posts
April 4, 202697 min
Next.js Proxy Deep Dive: Edge-First Request Interception
April 4, 2026164 min
Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards
April 4, 202691 min