Introduction
Server Actions are React's built-in solution for handling form submissions and data mutations directly from your components without manually creating API endpoints. They bridge the gap between client and server by letting you call server-side functions from client components with automatic form handling, progressive enhancement, and built-in security features.
While Server Actions simplify data mutation patterns, production usage requires careful attention to security, error handling, and performance. This guide covers everything from basic usage to advanced patterns, security hardening, and the architectural decisions that matter when deploying Server Actions in production.
Server Functions vs. Server Actions: Understanding the Terminology
Before diving deep, it is important to clarify a naming distinction that React introduced in September 2024. The React team updated its documentation to differentiate between Server Functions and Server Actions:
- Server Function is the broader term. A Server Function is any async function defined with the
'use server'directive that executes on the server. It can be used for data fetching, mutations, or any server-side logic. - Server Action is a specific use case. When a Server Function is passed to a
<form>via theactionprop or called from within an action handler, it becomes a Server Action.
This distinction matters because Server Functions are not limited to form submissions. They can be invoked from event handlers, useEffect hooks, and even other server-side code. Understanding this flexibility helps you choose the right pattern for each use case.
// This is a Server Function (general purpose)
'use server';
export async function fetchUserProfile(userId: string) {
return await db.users.findUnique({ where: { id: userId } });
}
// This is a Server Action (used with form action)
'use server';
export async function updateProfile(formData: FormData) {
const name = formData.get('name') as string;
await db.users.update({ where: { id: session.user.id }, data: { name } });
}How Server Actions Work Under the Hood
When you define a Server Action, React serializes the function call, sends it to the server, executes it, and returns the result. Under the hood, Server Actions exclusively use the POST HTTP method. This is a deliberate design choice — POST requests are not cached by browsers, not prefetched by crawlers, and semantically indicate a mutation.
The Request Lifecycle
- Client invokes the action: When a form submits or a function calls the Server Action, React intercepts the call.
- Serialization: React serializes the arguments (typically
FormData) into a format suitable for transmission. - POST request: A fetch POST request is sent to the server with the serialized data and a special action identifier.
- Server execution: The framework (e.g., Next.js) routes the request to the correct Server Function, executes it, and captures the return value.
- Response handling: React receives the result and updates the UI accordingly — including optimistic updates, error states, and revalidation.
What Happens Without JavaScript
Server Actions support progressive enhancement. Without JavaScript:
- The form submits as a regular POST request to the current URL.
- The framework intercepts this request and routes it to the appropriate Server Function.
- The server executes the function and returns the full HTML page with updated data.
This means your forms work even if JavaScript fails to load, which is critical for reliability, accessibility, and SEO.
// actions.ts
'use server';
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
// Runs on the server
await db.users.create({ data: { name, email } });
return { success: true };
}// Component using the action
'use client';
import { createUser } from './actions';
function CreateUserForm() {
return (
<form action={createUser}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Create User</button>
</form>
);
}Passing Server Functions as Props
Server Components can define Server Functions inline and pass them as props to Client Components. This pattern keeps the server-side logic close to where the data is rendered:
// Server Component
import Button from './Button';
function EmptyNote() {
async function createNoteAction() {
'use server';
await db.notes.create();
}
return <Button onClick={createNoteAction} />;
}// Client Component
'use client';
export default function Button({ onClick }: { onClick: () => Promise<void> }) {
return <button onClick={() => onClick()}>Create Empty Note</button>;
}When React renders the Server Component, it creates a reference to the function (identified by $$typeof: Symbol.for("react.server.reference") and an $$id). The Client Component receives this reference, and when invoked, React sends a POST request to execute the function on the server.
Security Considerations
The Critical Security Rule
Server Functions are reachable via direct POST requests, not just through your application's UI. Any HTTP client can call them. This means you must always verify authentication and authorization inside every Server Function. Never rely on client-side checks or assume the request came from your form.
Input Validation with Zod
Server Actions receive raw FormData from the client. You must validate and sanitize all inputs — never trust client-provided data.
'use server';
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
});
export async function createUser(formData: FormData) {
const raw = {
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
};
const parsed = createUserSchema.safeParse(raw);
if (!parsed.success) {
return {
success: false,
errors: parsed.error.flatten().fieldErrors,
};
}
const user = await db.users.create({ data: parsed.data });
return { success: true, userId: user.id };
}Authorization Checks
Always verify the user has permission to perform the action. Check both authentication (who the user is) and authorization (what they can do):
'use server';
export async function deleteUser(formData: FormData) {
const session = await getSession();
if (!session) {
throw new Error('Not authenticated');
}
const userId = formData.get('userId') as string;
// Check if user has admin role
if (session.user.role !== 'admin') {
throw new Error('Not authorized');
}
// Prevent self-deletion
if (session.user.id === userId) {
throw new Error('Cannot delete yourself');
}
await db.users.delete({ where: { id: userId } });
revalidatePath('/users');
}CSRF Protection
Server Actions in Next.js include built-in CSRF protection. Each action generates a unique token tied to the origin. This is one of the security benefits of using the framework's built-in support rather than custom API routes. However, if you're building a custom framework integration, ensure you validate the origin header:
'use server';
export async function sensitiveAction(formData: FormData) {
// Next.js handles CSRF automatically, but for custom setups:
const origin = headers().get('origin');
if (origin !== process.env.NEXT_PUBLIC_APP_URL) {
throw new Error('Invalid origin');
}
// Proceed with action...
}Rate Limiting
Protect sensitive actions from abuse by implementing rate limiting. This is especially important for authentication flows, contact forms, and any action that sends emails or writes to external services:
'use server';
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 submitContactForm(formData: FormData) {
const session = await getSession();
const identifier = session?.user?.id || headers().get('x-forwarded-for') || 'anonymous';
const { success, remaining } = await ratelimit.limit(identifier);
if (!success) {
return { success: false, error: 'Too many requests. Please try again later.' };
}
// Process form...
}Advanced Patterns
Optimistic Updates with useOptimistic
Optimistic updates provide instant feedback by immediately reflecting the expected result before the server confirms the mutation. React's useOptimistic hook makes this pattern straightforward:
'use client';
import { useOptimistic } from 'react';
import { addTodo } from './actions';
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: string) => [
...state,
{ id: crypto.randomUUID(), title: newTodo, completed: false, pending: true },
]
);
async function handleSubmit(formData: FormData) {
const title = formData.get('title') as string;
addOptimisticTodo(title);
await addTodo(formData);
}
return (
<>
<form action={handleSubmit}>
<input name="title" required />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.6 : 1 }}>
{todo.title}
</li>
))}
</ul>
</>
);
}The key advantage of useOptimistic is that it automatically reverts the optimistic state when the server action completes or fails, ensuring the UI stays consistent with the actual server state.
Form State with useActionState
useActionState is the recommended way to manage form state with Server Actions. It provides access to the action's return value, a wrapped action function, and a pending boolean:
'use client';
import { useActionState } from 'react';
import { createUser } from './actions';
function CreateUserForm() {
const [state, formAction, isPending] = useActionState(createUser, null);
return (
<form action={formAction}>
<input name="name" required />
{state?.errors?.name && <span>{state.errors.name[0]}</span>}
<input name="email" type="email" required />
{state?.errors?.email && <span>{state.errors.email[0]}</span>}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create User'}
</button>
{state?.success && <p>User created successfully!</p>}
</form>
);
}Progressive Enhancement with useActionState
A powerful feature of useActionState is its support for progressive enhancement via the third argument — a permalink URL:
'use client';
import { useActionState } from 'react';
import { updateName } from './actions';
function UpdateName() {
const [, submitAction] = useActionState(updateName, null, `/name/update`);
return (
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit">Update</button>
</form>
);
}When the permalink is provided, React will redirect to the provided URL if the form is submitted before the JavaScript bundle loads. Additionally, React automatically replays form submissions entered before hydration finishes, meaning users can interact with your app even before it has fully hydrated.
Composing Multiple Actions
In real applications, you often need multiple related actions for a single resource. Organize them in a dedicated actions file:
'use server';
export async function updateProfile(formData: FormData) {
const session = await getSession();
if (!session) throw new Error('Not authenticated');
const name = formData.get('name') as string;
const bio = formData.get('bio') as string;
await db.users.update({
where: { id: session.user.id },
data: { name, bio },
});
revalidatePath('/profile');
return { success: true };
}
export async function uploadAvatar(formData: FormData) {
const session = await getSession();
if (!session) throw new Error('Not authenticated');
const file = formData.get('avatar') as File;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadToS3(buffer, file.name);
await db.users.update({
where: { id: session.user.id },
data: { avatarUrl: url },
});
revalidatePath('/profile');
return { success: true, url };
}Server Actions with Revalidation
After performing mutations, you need to tell Next.js to refresh the data. There are three approaches, each suited to different scenarios:
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
authorId: (await getSession()).user.id,
},
});
// Revalidate a specific path — good for page-level cache invalidation
revalidatePath('/blog');
// Or revalidate by cache tag — good for granular cache control
revalidateTag('posts');
// Redirect after mutation — navigates the user to the new post
redirect(`/blog/${post.slug}`);
}The three revalidation approaches:
| Method | Use Case | Scope |
|---|---|---|
revalidatePath('/path') | When the page at that path needs fresh data | Invalidates all data for the path |
revalidateTag('tag') | When specific cached data needs refreshing | Invalidates only data tagged with that tag |
refresh() (from next/cache) | When you want to refresh the current page's data | Refreshes client router without revalidating tags |
Choose revalidatePath when a page's entire data set is stale. Choose revalidateTag when only specific data segments need updating, which is more efficient for complex caching strategies.
Performance Optimization
Debouncing Server Actions
For actions triggered by user input (like search), debounce the calls to avoid overwhelming the server:
'use client';
import { useTransition } from 'react';
import { searchProducts } from './actions';
function SearchInput() {
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState<Product[]>([]);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const query = e.target.value;
startTransition(async () => {
if (query.length >= 3) {
const data = await searchProducts(query);
setResults(data);
}
});
}
return (
<div>
<input onChange={handleChange} placeholder="Search..." />
{isPending && <span>Searching...</span>}
<SearchResults results={results} />
</div>
);
}Batching Mutations
When multiple related updates need to happen atomically, use database transactions:
'use server';
export async function batchUpdateItems(formData: FormData) {
const session = await getSession();
if (!session) throw new Error('Not authenticated');
const items = JSON.parse(formData.get('items') as string);
// Use a transaction for atomicity
await db.$transaction(
items.map((item: { id: string; quantity: number }) =>
db.cartItems.update({
where: { id: item.id },
data: { quantity: item.quantity },
})
)
);
revalidatePath('/cart');
}Caching Server Action Results
For Server Functions that perform expensive read operations, use Next.js's caching utilities to avoid redundant computation:
'use server';
import { unstable_cache } from 'next/cache';
export const getPopularPosts = unstable_cache(
async () => {
return await db.posts.findMany({
orderBy: { views: 'desc' },
take: 10,
});
},
['popular-posts'],
{ revalidate: 3600 } // Cache for 1 hour
);Streaming Responses
For long-running operations, stream results back to the client:
'use server';
export async function generateReport(formData: FormData) {
const session = await getSession();
if (!session) throw new Error('Not authenticated');
// Create a streaming response
const stream = new ReadableStream({
async start(controller) {
const chunks = await generateReportChunks(formData);
for (const chunk of chunks) {
controller.enqueue(JSON.stringify(chunk) + '\n');
}
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'application/x-ndjson' },
});
}Error Handling
Client-Side Error Handling
Wrap Server Action calls in try-catch blocks and return meaningful error states:
'use client';
import { useActionState } from 'react';
import { createUser } from './actions';
function CreateUserForm() {
const [state, formAction, isPending] = useActionState(
async (prevState: any, formData: FormData) => {
try {
const result = await createUser(formData);
if (result.success) {
router.push('/users');
}
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'An error occurred',
};
}
},
null
);
return (
<form action={formAction}>
{/* form fields */}
{state?.error && <div className="error">{state.error}</div>}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
</form>
);
}Server-Side Error Boundaries
Use Next.js error boundaries to catch unexpected errors at the route level:
// app/users/error.tsx
'use client';
export default function UsersError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Failed to load users</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}Handling Form Reset on Success
When a Server Action invoked via a <form action> completes successfully, React automatically resets the form. This is a deliberate behavior to clear the submission. If you need to preserve form state after success, use useActionState and control the form values manually, or use event.preventDefault() in a custom submit handler.
Testing Server Actions
Test Server Actions thoroughly to ensure they behave correctly under various conditions:
// __tests__/actions.test.ts
import { createUser } from '@/app/actions';
import { db } from '@/lib/db';
// Mock the database
jest.mock('@/lib/db');
// Mock the session
jest.mock('@/lib/session', () => ({
getSession: jest.fn().mockResolvedValue({
user: { id: 'user-1', role: 'admin' },
}),
}));
describe('createUser', () => {
it('creates a user with valid data', async () => {
const formData = new FormData();
formData.set('name', 'Alice');
formData.set('email', 'alice@example.com');
const result = await createUser(formData);
expect(result.success).toBe(true);
expect(db.users.create).toHaveBeenCalledWith({
data: { name: 'Alice', email: 'alice@example.com' },
});
});
it('returns errors for invalid data', async () => {
const formData = new FormData();
formData.set('name', 'A'); // Too short
formData.set('email', 'invalid');
const result = await createUser(formData);
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
it('rejects unauthenticated requests', async () => {
// Mock unauthenticated session
require('@/lib/session').getSession.mockResolvedValueOnce(null);
const formData = new FormData();
formData.set('name', 'Alice');
formData.set('email', 'alice@example.com');
await expect(createUser(formData)).rejects.toThrow('Not authenticated');
});
});Write unit tests for the action function itself, verifying that it correctly processes valid inputs and rejects invalid ones. Test error handling by simulating network failures, validation errors, and server errors. Use integration tests to verify that the form submission flow works correctly from the client through the server and back. Test edge cases like concurrent submissions, large payloads, and special characters in input fields.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| No input validation | Security vulnerabilities, data corruption | Use Zod schema validation |
| Missing authorization | Unauthorized data access | Check session and roles in every action |
| No error handling | White screen on errors | Return error states, use error boundaries |
| Not revalidating | Stale UI after mutations | Call revalidatePath or revalidateTag |
| Passing non-serializable data | Runtime errors | Use plain objects and primitives only |
| No rate limiting | Abuse and DoS potential | Implement rate limiting for sensitive actions |
| Client-side-only validation | False sense of security | Always validate server-side |
| Assuming form reset is optional | Unexpected UI behavior | React resets forms on success — plan accordingly |
| Using GET for mutations | CSRF exposure, caching issues | Server Actions use POST by design — leverage this |
Architecture Decisions
When to Use Server Actions vs. API Routes
| Scenario | Server Actions | API Routes |
|---|---|---|
| Form submissions | ✅ Ideal | ❌ Overhead |
| File uploads | ✅ Built-in FormData support | ⚠️ Manual handling |
| Third-party integrations | ⚠️ Limited to server-side calls | ✅ Public API endpoints |
| Real-time updates | ❌ Not designed for streaming | ✅ WebSocket/SSE support |
| CRUD mutations | ✅ Simplified pattern | ⚠️ More boilerplate |
| Complex validation flows | ✅ Zod integration | ✅ Middleware support |
Server Actions with Event Handlers
Beyond forms, Server Actions can be called from event handlers in Client Components. This is useful for interactions that don't involve form submission:
'use client';
import { incrementLike } from './actions';
import { useState } from 'react';
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return (
<>
<p>Total Likes: {likes}</p>
<button
onClick={async () => {
const updatedLikes = await incrementLike();
setLikes(updatedLikes);
}}
>
Like
</button>
</>
);
}When using Server Actions with event handlers, wrap the call in startTransition to access the isPending state:
'use client';
import { useTransition } from 'react';
import { incrementViews } from './actions';
function ViewCounter({ initialViews }: { initialViews: number }) {
const [views, setViews] = useState(initialViews);
const [isPending, startTransition] = useTransition();
useEffect(() => {
startTransition(async () => {
const updatedViews = await incrementViews();
setViews(updatedViews);
});
}, []);
return <p>Total Views: {views}</p>;
}Best Practices
- Always validate inputs — Use Zod or similar for schema validation on the server
- Check authorization — Verify user permissions before every mutation
- Use
useActionState— For form state management and pending states - Revalidate after mutations — Keep UI in sync with server state using the appropriate method
- Handle errors gracefully — Return error states, don't throw to the client without catch
- Rate limit sensitive actions — Prevent abuse of form submissions and authentication flows
- Use transactions — Ensure atomicity for multi-step mutations
- Test server actions — Unit test with mocked dependencies, integration test the full flow
- Prefer
revalidateTag— For granular cache invalidation over full path revalidation - Leverage progressive enhancement — Your forms should work without JavaScript
Conclusion
Server Actions represent a paradigm shift in how React handles data mutations. By treating server-side functions as first-class citizens in the component tree, React eliminates the need for manual API endpoint creation while providing built-in progressive enhancement and security features.
The key to production success is treating Server Actions with the same rigor as any API endpoint: validate inputs, check authorization, handle errors, and monitor performance. Combined with React's concurrent features like useOptimistic and useActionState, Server Actions enable responsive, resilient user interfaces that degrade gracefully.
Key takeaways:
- Validate all inputs — Never trust client data, even from forms
- Check authorization — Server Actions are not a replacement for auth
- Use progressive enhancement — Forms work without JavaScript
- Leverage
useOptimistic— Instant UI feedback for mutations - Revalidate after mutations — Keep server and client state in sync
- Rate limit sensitive actions — Protect against abuse
- Test thoroughly — Unit test actions with mocked dependencies