React Server Actions: The Complete Technical Deep Dive
React Server Actions: The Complete Technical Deep Dive
Understanding the Architecture Behind Server-Side Mutations in React
Table of Contents
- Introduction
- The Problem Server Actions Solve
- Core Concepts
- The 'use server' Directive
- How Server Actions Work Under the Hood
- Form Integration
- Progressive Enhancement
- Serialization and the Wire Protocol
- Closures and Bound Arguments
- Integration with React Transitions
- useActionState Hook
- useFormStatus Hook
- useOptimistic Hook
- Revalidation and Cache Integration
- Security Model
- Error Handling
- Advanced Patterns
- Server Actions vs API Routes
- Framework Implementations
- Performance Considerations
- Debugging Server Actions
- Future Directions
Introduction
React Server Actions represent a paradigm shift in how we handle mutations in React applications. They allow you to define asynchronous server functions that can be invoked directly from client components, eliminating the traditional API layer for mutations.
// This function runs on the SERVER
async function createPost(formData) {
'use server';
const title = formData.get('title');
await db.posts.create({ title });
revalidatePath('/posts');
}
// This component runs on the CLIENT
function NewPostForm() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}
The Problem Server Actions Solve
Traditional Mutation Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Traditional React Mutation Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Client Component API Route Database │
│ ┌──────────────┐ ┌──────────┐ ┌────────┐ │
│ │ │ │ │ │ │ │
│ │ onClick() ───┼──fetch()──►│ POST /api│───────────►│ DB │ │
│ │ │ │ /posts │ │ │ │
│ │ Loading... │◄───JSON────┤ │◄──────────┤ │ │
│ │ │ │ │ │ │ │
│ │ Update UI │ └──────────┘ └────────┘ │
│ │ │ │
│ └──────────────┘ │
│ │
│ Problems: │
│ • Boilerplate: fetch, error handling, loading states │
│ • Type safety gap between client and server │
│ • Manual cache invalidation │
│ • No progressive enhancement │
│ • Duplicate validation logic │
│ │
└─────────────────────────────────────────────────────────────────┘
Server Actions Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Server Actions Mutation Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Client Component Server │
│ ┌──────────────┐ ┌───────────────────────┐ │
│ │ │ │ │ │
│ │ <form │ │ async function │ │
│ │ action={ │───POST────►│ createPost() { │ │
│ │ createPost}>│ FormData │ 'use server'; │ │
│ │ │ │ await db.insert(); │ │
│ │ │◄──Flight───┤ revalidatePath(); │ │
│ │ Auto-update │ Payload │ } │ │
│ │ │ │ │ │
│ └──────────────┘ └───────────────────────┘ │
│ │
│ Benefits: │
│ ✓ Zero boilerplate │
│ ✓ End-to-end type safety │
│ ✓ Automatic cache revalidation │
│ ✓ Progressive enhancement (works without JS) │
│ ✓ Integrated with React transitions │
│ ✓ Optimistic updates built-in │
│ │
└─────────────────────────────────────────────────────────────────┘
Core Concepts
What is a Server Action?
A Server Action is an asynchronous function that:
- Executes exclusively on the server
- Can be called from client components
- Has access to server-only resources (database, filesystem, secrets)
- Returns serializable data via React Flight protocol
// Definition
async function serverAction(formData) {
'use server';
// This code ONLY runs on the server
// It's never sent to the client bundle
}
// Invocation methods
<form action={serverAction}> // Form action
<button formAction={serverAction}> // Button formAction
await serverAction(data); // Direct call
startTransition(() => serverAction()); // With transition
Server Action Characteristics
┌─────────────────────────────────────────────────────────────────┐
│ Server Action Properties │
├─────────────────────────────────────────────────────────────────┤
│ │
│ EXECUTION CONTEXT │
│ • Runs in Node.js/Edge runtime on server │
│ • Has access to request headers, cookies │
│ • Can read/write to database directly │
│ • Environment variables available │
│ │
│ SECURITY │
│ • Code never shipped to client │
│ • Each action gets unique endpoint ID │
│ • Arguments validated and sanitized │
│ • CSRF protection built-in │
│ │
│ DATA FLOW │
│ • Input: FormData or serializable arguments │
│ • Output: Serializable return value (via Flight) │
│ • Can trigger revalidation of cached data │
│ • Can redirect to different routes │
│ │
│ INTEGRATION │
│ • Works with React Suspense │
│ • Integrates with transitions │
│ • Supports optimistic updates │
│ • Progressive enhancement compatible │
│ │
└─────────────────────────────────────────────────────────────────┘
The 'use server' Directive
Directive Placement
// ═══════════════════════════════════════════════════════════════
// OPTION 1: Module-level directive (all exports are Server Actions)
// ═══════════════════════════════════════════════════════════════
// app/actions.js
'use server';
export async function createUser(formData) {
// Server Action
}
export async function deleteUser(id) {
// Server Action
}
export const updateUser = async (id, data) => {
// Server Action
};
// ═══════════════════════════════════════════════════════════════
// OPTION 2: Function-level directive (inline in Server Components)
// ═══════════════════════════════════════════════════════════════
// app/page.js (Server Component)
export default function Page() {
async function submitForm(formData) {
'use server'; // Marks this specific function
await db.insert(formData);
}
return <form action={submitForm}>...</form>;
}
// ═══════════════════════════════════════════════════════════════
// INVALID: Cannot define Server Actions in Client Components
// ═══════════════════════════════════════════════════════════════
'use client';
export function ClientComponent() {
// ❌ ERROR: Server Actions cannot be defined in Client Components
async function action() {
'use server'; // This will fail
}
}
// ✅ CORRECT: Import from a 'use server' module
'use client';
import { action } from './actions'; // actions.js has 'use server'
export function ClientComponent() {
return <form action={action}>...</form>;
}
Compilation Transformation
// ═══════════════════════════════════════════════════════════════
// SOURCE CODE
// ═══════════════════════════════════════════════════════════════
// app/actions.js
'use server';
export async function createPost(title, content) {
const post = await db.posts.create({ title, content });
return post.id;
}
// ═══════════════════════════════════════════════════════════════
// AFTER COMPILATION (Server Bundle)
// ═══════════════════════════════════════════════════════════════
// The actual function remains on server
async function createPost(title, content) {
const post = await db.posts.create({ title, content });
return post.id;
}
// Registered with action ID
registerServerAction('a1b2c3d4', createPost);
// ═══════════════════════════════════════════════════════════════
// AFTER COMPILATION (Client Bundle)
// ═══════════════════════════════════════════════════════════════
// Function replaced with reference
export const createPost = createServerReference('a1b2c3d4');
// createServerReference returns a callable that:
// 1. Serializes arguments
// 2. Sends POST request to server
// 3. Deserializes and returns response
How Server Actions Work Under the Hood
Request/Response Flow
┌─────────────────────────────────────────────────────────────────┐
│ Server Action Request Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CLIENT SERVER │
│ │
│ 1. User submits form │
│ │ │
│ ▼ │
│ 2. React intercepts submission │
│ │ │
│ ▼ │
│ 3. Serialize arguments (FormData or Flight format) │
│ │ │
│ ▼ │
│ 4. POST /_rsc?actionId=a1b2c3d4 ──────────────────────────► │
│ │ Headers: │
│ │ Content-Type: multipart/form-data │
│ │ Next-Action: a1b2c3d4 │
│ │ Next-Router-State-Tree: [encoded tree] │
│ │ │
│ │ 5. Lookup action by ID │
│ │ │ │
│ │ ▼ │
│ │ 6. Deserialize arguments │
│ │ │ │
│ │ ▼ │
│ │ 7. Execute action function │
│ │ │ │
│ │ ▼ │
│ │ 8. Serialize return value │
│ │ │ │
│ 9. ◄──────────────────────────────── │
│ │ Response: │
│ │ Content-Type: text/x-component │
│ │ Body: Flight payload │
│ │ │
│ ▼ │
│ 10. Parse Flight response │
│ │ │
│ ▼ │
│ 11. Update UI with new data │
│ │
└─────────────────────────────────────────────────────────────────┘
Action ID Generation
// Action IDs are generated at build time based on:
// 1. File path
// 2. Export name
// 3. Build-time hash
// Example ID generation (simplified)
function generateActionId(filePath, exportName) {
const input = `${filePath}#${exportName}`;
return createHash('sha256')
.update(input)
.digest('hex')
.slice(0, 32);
}
// app/actions.js#createPost → "a1b2c3d4e5f6..."
// The ID is stable across builds if the function signature doesn't change
// This enables:
// - Caching of action references
// - Safe hot-reloading during development
// - Predictable endpoint URLs
Server-Side Action Handler
// Simplified implementation of server action handling
async function handleServerAction(request) {
// 1. Extract action ID from request
const actionId = request.headers.get('Next-Action');
// 2. Look up the action function
const action = serverActionManifest[actionId];
if (!action) {
throw new Error(`Unknown action: ${actionId}`);
}
// 3. Parse request body
const contentType = request.headers.get('Content-Type');
let args;
if (contentType?.includes('multipart/form-data')) {
// Form submission
args = [await request.formData()];
} else {
// Direct invocation with Flight-encoded arguments
const body = await request.text();
args = decodeServerActionArgs(body);
}
// 4. Execute the action
let result;
try {
result = await action.apply(null, args);
} catch (error) {
// Encode error for client
return encodeServerActionError(error);
}
// 5. Encode result as Flight payload
const flightData = await renderToFlightResponse(result);
// 6. Include revalidation headers if needed
const headers = new Headers({
'Content-Type': 'text/x-component',
});
if (pendingRevalidations.length > 0) {
headers.set('x-action-revalidated', JSON.stringify(pendingRevalidations));
}
return new Response(flightData, { headers });
}
Form Integration
Native Form Action
// Server Action with FormData
async function createUser(formData) {
'use server';
const name = formData.get('name');
const email = formData.get('email');
const avatar = formData.get('avatar'); // File upload works!
// Validate
if (!name || !email) {
return { error: 'Name and email required' };
}
// Process file
if (avatar && avatar.size > 0) {
await uploadToStorage(avatar);
}
// Create user
const user = await db.users.create({ name, email });
// Redirect after success
redirect(`/users/${user.id}`);
}
// Form component
function SignupForm() {
return (
<form action={createUser}>
<input name="name" required />
<input name="email" type="email" required />
<input name="avatar" type="file" accept="image/*" />
<button type="submit">Sign Up</button>
</form>
);
}
Multiple Actions Per Form
async function saveAsDraft(formData) {
'use server';
await db.posts.upsert({ ...data, status: 'draft' });
}
async function publish(formData) {
'use server';
await db.posts.upsert({ ...data, status: 'published' });
revalidatePath('/posts');
}
function PostEditor() {
return (
<form>
<input name="title" />
<textarea name="content" />
{/* Different actions for different buttons */}
<button formAction={saveAsDraft}>Save Draft</button>
<button formAction={publish}>Publish</button>
</form>
);
}
Hidden Fields for Additional Data
async function updatePost(formData) {
'use server';
const id = formData.get('id');
const title = formData.get('title');
await db.posts.update(id, { title });
}
function EditPostForm({ post }) {
return (
<form action={updatePost}>
{/* Hidden field carries the ID */}
<input type="hidden" name="id" value={post.id} />
<input name="title" defaultValue={post.title} />
<button type="submit">Update</button>
</form>
);
}
Progressive Enhancement
Server Actions are designed to work without JavaScript. This is called progressive enhancement.
How It Works
┌─────────────────────────────────────────────────────────────────┐
│ Progressive Enhancement │
├─────────────────────────────────────────────────────────────────┤
│ │
│ WITH JAVASCRIPT: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. User clicks submit │ │
│ │ 2. React prevents default form submission │ │
│ │ 3. React serializes form data │ │
│ │ 4. Fetch POST to action endpoint │ │
│ │ 5. Parse Flight response │ │
│ │ 6. Update UI seamlessly (no page reload) │ │
│ │ │ │
│ │ Benefits: │ │
│ │ • Optimistic updates │ │
│ │ • Loading states │ │
│ │ • Partial updates │ │
│ │ • No page flicker │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ WITHOUT JAVASCRIPT: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. User clicks submit │ │
│ │ 2. Browser performs native form POST │ │
│ │ 3. Server receives multipart/form-data │ │
│ │ 4. Server executes action │ │
│ │ 5. Server returns full HTML page │ │
│ │ 6. Browser renders new page (full page reload) │ │
│ │ │ │
│ │ Benefits: │ │
│ │ • Works immediately on page load │ │
│ │ • Works with JS disabled │ │
│ │ • Better for slow connections │ │
│ │ • Accessibility │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Server Response for No-JS Submissions
// When form submitted without JS, server detects via headers
async function handleServerAction(request) {
const acceptHeader = request.headers.get('Accept');
const isNoJS = !acceptHeader?.includes('text/x-component');
// Execute action
await action(formData);
if (isNoJS) {
// Return full HTML page (traditional form behavior)
return new Response(await renderFullPage(), {
headers: { 'Content-Type': 'text/html' }
});
} else {
// Return Flight payload for client-side update
return new Response(flightPayload, {
headers: { 'Content-Type': 'text/x-component' }
});
}
}
Serialization and the Wire Protocol
Argument Serialization
Server Actions support a specific set of serializable types:
// ═══════════════════════════════════════════════════════════════
// SUPPORTED TYPES
// ═══════════════════════════════════════════════════════════════
// Primitives
await action(42); // number
await action("hello"); // string
await action(true); // boolean
await action(null); // null
await action(undefined); // undefined
await action(123n); // BigInt
// Objects & Arrays
await action({ a: 1, b: 2 }); // Plain object
await action([1, 2, 3]); // Array
// Built-in Objects
await action(new Date()); // Date
await action(new Map([['a', 1]])); // Map
await action(new Set([1, 2, 3])); // Set
await action(new FormData()); // FormData
await action(new URLSearchParams());// URLSearchParams
// Binary Data
await action(new ArrayBuffer(8)); // ArrayBuffer
await action(new Uint8Array([1,2]));// TypedArrays
await action(new Blob(['data'])); // Blob
await action(new File([], 'f.txt'));// File
// React-specific
await action(<div>JSX</div>); // React Elements (limited)
// ═══════════════════════════════════════════════════════════════
// NOT SUPPORTED
// ═══════════════════════════════════════════════════════════════
// ❌ Functions (cannot serialize)
await action(() => {});
// ❌ Class instances (except built-ins)
await action(new MyClass());
// ❌ Symbols
await action(Symbol('foo'));
// ❌ WeakMap / WeakSet
await action(new WeakMap());
// ❌ Circular references (throws)
const obj = {}; obj.self = obj;
await action(obj);
FormData Encoding
// Form submission creates multipart/form-data
// Client
<form action={createPost}>
<input name="title" value="Hello" />
<input name="tags" value="react" />
<input name="tags" value="server" />
<input name="file" type="file" />
</form>
// Wire format (multipart/form-data)
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="title"
Hello
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="tags"
react
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="tags"
server
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="doc.pdf"
Content-Type: application/pdf
[binary data]
------WebKitFormBoundary7MA4YWxkTrZu0gW--
// Server receives FormData
async function createPost(formData) {
'use server';
formData.get('title'); // "Hello"
formData.getAll('tags'); // ["react", "server"]
formData.get('file'); // File object
}
Flight-Encoded Arguments
// Direct invocation uses Flight encoding
// Client call
await updateUser(userId, { name: 'John', age: 30 });
// Request body (Flight format)
0:["$ActionArg",["user-123",{"name":"John","age":30}]]
// Complex types
await action(new Date('2024-01-01'), new Set([1, 2, 3]));
// Request body
0:["$ActionArg",["$D2024-01-01T00:00:00.000Z","$W[1,2,3]"]]
// Date prefix: $D Set prefix: $W
Closures and Bound Arguments
How Closures Work
Server Actions can capture variables from their enclosing scope. These become bound arguments.
// Server Component
async function UserPage({ userId }) {
// This value is captured in the closure
const user = await db.users.find(userId);
async function updateName(formData) {
'use server';
// `userId` is bound from outer scope
const newName = formData.get('name');
await db.users.update(userId, { name: newName });
revalidatePath(`/users/${userId}`);
}
return (
<form action={updateName}>
<input name="name" defaultValue={user.name} />
<button>Update</button>
</form>
);
}
Bound Arguments Serialization
// ═══════════════════════════════════════════════════════════════
// COMPILATION TRANSFORMATION
// ═══════════════════════════════════════════════════════════════
// SOURCE
async function Page({ postId }) {
async function deletePost() {
'use server';
await db.posts.delete(postId); // postId captured
}
return <button onClick={deletePost}>Delete</button>;
}
// COMPILED (Server)
async function deletePost$$bound(postId) {
await db.posts.delete(postId);
}
registerServerAction('abc123', deletePost$$bound);
// COMPILED (Client reference)
// The bound value is encrypted and sent with each request
// Wire format when action is invoked:
POST /_rsc?actionId=abc123
Content-Type: text/x-component
0:["$F","abc123",["encrypted_postId_value"]]
// │ │ └── Bound arguments (encrypted)
// │ └── Action ID
// └── Server Reference marker
Security of Bound Arguments
// Bound arguments are ENCRYPTED before being sent to client
// This prevents tampering
// Server generates:
// 1. Serialize bound args: { postId: 'post-123' }
// 2. Encrypt with server secret: "aGVsbG8gd29ybGQ..."
// 3. Send encrypted blob to client
// Client sends back:
// 1. Encrypted blob: "aGVsbG8gd29ybGQ..."
// 2. Server decrypts and validates
// 3. Original values restored
// If user tries to modify:
// - Decryption fails → request rejected
// - This prevents privilege escalation attacks
Bind for Additional Arguments
// Using .bind() to pass additional arguments
async function deleteItem(itemId, formData) {
'use server';
await db.items.delete(itemId);
}
function ItemList({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name}
{/* Bind itemId as first argument */}
<form action={deleteItem.bind(null, item.id)}>
<button>Delete</button>
</form>
</li>
))}
</ul>
);
}
// Each form has a unique action with bound itemId
// Wire format includes bound args:
// POST /_rsc?actionId=def456
// Body: 0:["$F","def456",["item-1"]] ← bound itemId
Integration with React Transitions
Automatic Transition Wrapping
When a Server Action is invoked via form action, React automatically wraps it in a transition:
// This form submission is automatically a transition
<form action={createPost}>
<input name="title" />
<button>Create</button>
</form>
// Equivalent to:
function handleSubmit(formData) {
startTransition(async () => {
await createPost(formData);
});
}
Manual Transition with Direct Calls
'use client';
import { useTransition } from 'react';
import { deletePost } from './actions';
function DeleteButton({ postId }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
startTransition(async () => {
await deletePost(postId);
});
};
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
);
}
Why Transitions Matter
┌─────────────────────────────────────────────────────────────────┐
│ Transitions and Server Actions │
├─────────────────────────────────────────────────────────────────┤
│ │
│ WITHOUT TRANSITION: │
│ • UI blocks during action execution │
│ • No pending state indication │
│ • Jarring user experience │
│ │
│ WITH TRANSITION: │
│ • UI remains responsive during action │
│ • isPending indicates loading state │
│ • Can show optimistic updates │
│ • Previous UI visible until action completes │
│ • Multiple actions can be batched │
│ │
│ Timeline: │
│ │
│ User clicks ──► Start transition ──► Action executes │
│ │ │ │ │
│ │ ▼ │ │
│ │ isPending=true │ │
│ │ (show spinner) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ UI remains │ │
│ │ interactive │ │
│ │ │ │ │
│ │ │◄─────────────────────┘ │
│ │ ▼ │
│ │ isPending=false │
│ │ (new UI rendered) │
│ │ │
│ │
└─────────────────────────────────────────────────────────────────┘
useActionState Hook
useActionState (formerly useFormState) manages action state, including pending status and return values.
Basic Usage
'use client';
import { useActionState } from 'react';
import { createUser } from './actions';
function SignupForm() {
const [state, formAction, isPending] = useActionState(
createUser,
null // Initial state
);
return (
<form action={formAction}>
<input name="email" />
<input name="password" type="password" />
{state?.error && (
<p className="error">{state.error}</p>
)}
<button disabled={isPending}>
{isPending ? 'Signing up...' : 'Sign Up'}
</button>
</form>
);
}
Action with Return Value
// actions.js
'use server';
export async function createUser(prevState, formData) {
const email = formData.get('email');
const password = formData.get('password');
// Validation
if (password.length < 8) {
return {
error: 'Password must be at least 8 characters',
email // Preserve email for form
};
}
// Check if user exists
const existing = await db.users.findByEmail(email);
if (existing) {
return { error: 'Email already registered', email };
}
// Create user
try {
const user = await db.users.create({ email, password });
redirect(`/welcome/${user.id}`);
} catch (e) {
return { error: 'Failed to create account' };
}
}
useActionState Internals
// Simplified implementation
function useActionState(action, initialState, permalink) {
const [state, setState] = useState(initialState);
const [isPending, startTransition] = useTransition();
const formAction = async (formData) => {
startTransition(async () => {
const newState = await action(state, formData);
setState(newState);
});
};
// For progressive enhancement, encode state in URL
if (permalink) {
// State is serialized and included in form action URL
}
return [state, formAction, isPending];
}
Progressive Enhancement with Permalink
// State persists across no-JS form submissions
const [state, formAction, isPending] = useActionState(
createUser,
null,
'/signup' // Permalink for state recovery
);
// Without JS:
// 1. Form POSTs to /signup
// 2. Server executes action
// 3. If validation fails, redirects to /signup?state=encoded
// 4. Page re-renders with error state from URL
useFormStatus Hook
useFormStatus provides information about the parent form's submission state.
Usage
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
function ContactForm() {
return (
<form action={sendMessage}>
<input name="message" />
<SubmitButton /> {/* Must be inside form */}
</form>
);
}
useFormStatus Return Value
interface FormStatus {
pending: boolean; // True while form is submitting
data: FormData | null; // FormData being submitted
method: string | null; // HTTP method (usually 'POST')
action: string | ((formData: FormData) => void) | null;
}
// Note: useFormStatus must be called from a component
// that is a CHILD of the <form> element.
// ❌ Wrong - not inside form
function Form() {
const status = useFormStatus(); // Won't work
return <form>...</form>;
}
// ✅ Correct - inside form via child component
function Form() {
return (
<form>
<SubmitButton /> {/* useFormStatus called here */}
</form>
);
}
Showing Submitted Data
function ReviewSubmission() {
const { pending, data } = useFormStatus();
if (!pending || !data) return null;
return (
<div className="preview">
<p>Sending: {data.get('message')}</p>
</div>
);
}
useOptimistic Hook
useOptimistic enables optimistic UI updates that revert if the action fails.
Basic Usage
'use client';
import { useOptimistic } from 'react';
import { addTodo } from './actions';
function TodoList({ todos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo) => [...state, { ...newTodo, pending: true }]
);
async function handleSubmit(formData) {
const title = formData.get('title');
// Optimistically add the todo
addOptimisticTodo({ id: crypto.randomUUID(), title });
// Actually create it on server
await addTodo(formData);
}
return (
<>
<form action={handleSubmit}>
<input name="title" />
<button>Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.title}
{todo.pending && ' (saving...)'}
</li>
))}
</ul>
</>
);
}
How Optimistic Updates Work
┌─────────────────────────────────────────────────────────────────┐
│ Optimistic Update Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ T=0 User submits form │
│ │ │
│ ▼ │
│ T=1 addOptimisticTodo() called │
│ │ │
│ ├──► UI immediately shows new todo (pending: true) │
│ │ │
│ ▼ │
│ T=2 Server action begins executing │
│ │ │
│ │ (User sees optimistic UI) │
│ │ │
│ ▼ │
│ T=500 Server action completes │
│ │ │
│ ├─── SUCCESS ──► Real data replaces optimistic │
│ │ (pending: false) │
│ │ │
│ └─── FAILURE ──► Optimistic state reverts │
│ Error shown to user │
│ │
└─────────────────────────────────────────────────────────────────┘
Complex Optimistic Patterns
// Optimistic delete with undo
function TodoItem({ todo, onDelete }) {
const [optimisticState, setOptimisticState] = useOptimistic(
{ deleted: false },
(state, action) => {
if (action === 'delete') return { deleted: true };
if (action === 'restore') return { deleted: false };
return state;
}
);
if (optimisticState.deleted) {
return (
<li className="deleted">
<span>Deleted</span>
<button onClick={() => setOptimisticState('restore')}>
Undo
</button>
</li>
);
}
return (
<li>
{todo.title}
<button onClick={() => {
setOptimisticState('delete');
onDelete(todo.id);
}}>
Delete
</button>
</li>
);
}
Revalidation and Cache Integration
Revalidation Functions
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function createPost(formData) {
const post = await db.posts.create({
title: formData.get('title'),
content: formData.get('content'),
});
// Revalidate specific path
revalidatePath('/posts');
// Revalidate specific page with specific params
revalidatePath(`/posts/${post.id}`);
// Revalidate layout
revalidatePath('/posts', 'layout');
// Revalidate by cache tag
revalidateTag('posts');
revalidateTag(`post-${post.id}`);
}
Cache Tag System
// Fetching with tags
async function getPosts() {
const response = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] } // Tag this cache entry
});
return response.json();
}
async function getPost(id) {
const response = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ['posts', `post-${id}`] }
});
return response.json();
}
// Server Action invalidates by tag
async function updatePost(id, formData) {
'use server';
await db.posts.update(id, { title: formData.get('title') });
// Invalidate this specific post's cache
revalidateTag(`post-${id}`);
// Also invalidate list cache
revalidateTag('posts');
}
Revalidation Flow
┌─────────────────────────────────────────────────────────────────┐
│ Revalidation Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CACHE STATE │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ /posts │ tags: ['posts'] │ FRESH │ │
│ │ /posts/1 │ tags: ['posts','p-1'] │ FRESH │ │
│ │ /posts/2 │ tags: ['posts','p-2'] │ FRESH │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ SERVER ACTION: updatePost(1, data) │
│ └─► revalidateTag('p-1') │
│ │
│ CACHE STATE (after) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ /posts │ tags: ['posts'] │ FRESH │ │
│ │ /posts/1 │ tags: ['posts','p-1'] │ STALE ← │ │
│ │ /posts/2 │ tags: ['posts','p-2'] │ FRESH │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Next request to /posts/1: │
│ → Cache miss (stale) → Re-fetch → Cache update │
│ │
└─────────────────────────────────────────────────────────────────┘
Security Model
Attack Surface Analysis
┌─────────────────────────────────────────────────────────────────┐
│ Server Action Security Model │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ATTACK VECTOR PROTECTION │
│ ───────────────────────────────────────────────────────────── │
│ │
│ CSRF Origin validation │
│ (Cross-Site Request Same-origin policy │
│ Forgery) SameSite cookies │
│ │
│ Injection Arguments are deserialized, │
│ (SQL, Command, etc.) not evaluated │
│ Use parameterized queries │
│ │
│ Privilege Escalation Bound args encrypted │
│ (Modify action params) Server-side validation required │
│ Check user permissions │
│ │
│ Enumeration Action IDs are hashed │
│ (Guess action endpoints) Non-sequential │
│ │
│ Replay Attacks Implement idempotency keys │
│ Use nonces for sensitive ops │
│ │
│ Data Exposure Server code never in client bundle │
│ Return only needed data │
│ │
└─────────────────────────────────────────────────────────────────┘
Authentication and Authorization
'use server';
import { cookies } from 'next/headers';
import { verifyToken, getUserPermissions } from '@/lib/auth';
// Middleware pattern for auth
async function withAuth(action) {
return async (...args) => {
const token = cookies().get('session')?.value;
if (!token) {
throw new Error('Unauthorized');
}
const user = await verifyToken(token);
if (!user) {
throw new Error('Invalid session');
}
// Inject user into action
return action(user, ...args);
};
}
// Protected action
export const deletePost = withAuth(async (user, postId) => {
// Check ownership
const post = await db.posts.find(postId);
if (post.authorId !== user.id) {
// Check admin permission
const permissions = await getUserPermissions(user.id);
if (!permissions.includes('admin')) {
throw new Error('Forbidden');
}
}
await db.posts.delete(postId);
revalidatePath('/posts');
});
Input Validation
'use server';
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
tags: z.array(z.string()).max(5).optional(),
});
export async function createPost(formData) {
// Parse and validate input
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags'),
};
const result = createPostSchema.safeParse(rawData);
if (!result.success) {
return {
error: 'Validation failed',
issues: result.error.issues,
};
}
const { title, content, tags } = result.data;
// Safe to use validated data
await db.posts.create({ title, content, tags });
}
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 per minute
});
export async function sendMessage(formData) {
const ip = headers().get('x-forwarded-for') ?? '127.0.0.1';
const { success, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return {
error: `Rate limited. Try again in ${Math.ceil((reset - Date.now()) / 1000)}s`,
};
}
// Proceed with action
await db.messages.create({
content: formData.get('message'),
});
return { success: true, remaining };
}
Error Handling
Error Types
'use server';
// 1. Validation errors - return as state
export async function createUser(formData) {
const email = formData.get('email');
if (!isValidEmail(email)) {
return { error: 'Invalid email format' }; // Graceful
}
// ...
}
// 2. Expected errors - throw custom error
export async function transferFunds(formData) {
const amount = parseFloat(formData.get('amount'));
const balance = await getBalance();
if (amount > balance) {
throw new Error('Insufficient funds'); // Caught by error boundary
}
}
// 3. Unexpected errors - let them bubble
export async function processPayment(formData) {
// If Stripe throws, it bubbles up
await stripe.charges.create({
amount: formData.get('amount'),
source: formData.get('token'),
});
}
Error Boundary Integration
'use client';
import { useActionState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div className="error-container">
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function Form() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<InnerForm />
</ErrorBoundary>
);
}
function InnerForm() {
const [state, action] = useActionState(submitForm, null);
return (
<form action={action}>
{state?.error && <p className="field-error">{state.error}</p>}
<input name="data" />
<button>Submit</button>
</form>
);
}
Global Error Handling
// app/error.js
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>Action failed</h2>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
);
}
// The error boundary catches unhandled errors from:
// - Server Components
// - Server Actions
// - Client Components
Advanced Patterns
Chained Actions
'use server';
export async function createProjectWithTasks(formData) {
// Transaction-like behavior
const project = await db.projects.create({
name: formData.get('projectName'),
});
const taskNames = formData.getAll('tasks');
await Promise.all(
taskNames.map(name =>
db.tasks.create({
projectId: project.id,
name,
})
)
);
revalidatePath('/projects');
redirect(`/projects/${project.id}`);
}
Streaming Responses
'use server';
// Server Actions can return Promises that resolve over time
// Combined with Suspense for streaming UI updates
export async function generateReport(formData) {
const reportId = await startReportGeneration(formData);
// Return immediately with report ID
// Client can poll or subscribe for updates
return { reportId, status: 'processing' };
}
// Alternative: Use Server-Sent Events alongside actions
export async function subscribeToReport(reportId) {
'use server';
// Returns a ReadableStream (not yet fully supported)
}
Middleware Pattern
// lib/action-middleware.js
'use server';
export function createAction(config) {
return async function action(...args) {
// Pre-execution hooks
if (config.auth) {
await validateAuth();
}
if (config.rateLimit) {
await checkRateLimit(config.rateLimit);
}
// Execute
const result = await config.handler(...args);
// Post-execution hooks
if (config.revalidate) {
config.revalidate.forEach(tag => revalidateTag(tag));
}
if (config.log) {
await logAction(config.name, args, result);
}
return result;
};
}
// Usage
export const createPost = createAction({
name: 'createPost',
auth: true,
rateLimit: { requests: 10, window: '1m' },
revalidate: ['posts'],
log: true,
handler: async (formData) => {
return db.posts.create({
title: formData.get('title'),
});
},
});
Parallel Actions
'use client';
import { useTransition } from 'react';
function Dashboard({ items }) {
const [isPending, startTransition] = useTransition();
const handleBulkDelete = (selectedIds) => {
startTransition(async () => {
// Execute multiple actions in parallel
await Promise.all(
selectedIds.map(id => deleteItem(id))
);
});
};
return (
<div>
<button onClick={() => handleBulkDelete(selectedIds)}>
Delete Selected ({selectedIds.length})
</button>
{isPending && <p>Deleting...</p>}
</div>
);
}
Action Composition
'use server';
// Base actions
async function logActivity(userId, action, details) {
await db.activityLog.create({ userId, action, details });
}
async function sendNotification(userId, message) {
await notificationService.send(userId, message);
}
// Composed action
export async function completeTask(taskId, formData) {
const task = await db.tasks.findById(taskId);
const user = await getCurrentUser();
// Update task
await db.tasks.update(taskId, {
status: 'completed',
completedAt: new Date(),
});
// Composed side effects
await Promise.all([
logActivity(user.id, 'TASK_COMPLETED', { taskId }),
sendNotification(task.assignerId, `Task "${task.name}" completed`),
]);
revalidatePath(`/tasks/${taskId}`);
}
Server Actions vs API Routes
Comparison
┌─────────────────────────────────────────────────────────────────┐
│ Server Actions vs API Routes │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ASPECT SERVER ACTIONS API ROUTES │
│ ───────────────────────────────────────────────────────────── │
│ │
│ Definition In-component or Separate file │
│ dedicated file (app/api/*) │
│ │
│ Type Safety Full (same codebase) Manual (schemas) │
│ │
│ Invocation Function call fetch() │
│ │
│ Progressive Yes (form action) No │
│ Enhancement │
│ │
│ Caching Automatic with Manual │
│ revalidate* │
│ │
│ External Access No Yes (public API) │
│ │
│ File Uploads FormData (native) Manual parsing │
│ │
│ Streaming Limited Full control │
│ │
│ WebSockets No Yes │
│ │
│ Best For Mutations External APIs, │
│ Form handling Webhooks, │
│ CRUD operations Complex streaming │
│ │
└─────────────────────────────────────────────────────────────────┘
When to Use Each
// ═══════════════════════════════════════════════════════════════
// USE SERVER ACTIONS FOR:
// ═══════════════════════════════════════════════════════════════
// Form submissions
<form action={createUser}>
// Mutations with revalidation
async function likePost(postId) {
'use server';
await db.likes.create({ postId, userId: user.id });
revalidatePath(`/posts/${postId}`);
}
// CRUD operations
async function updateProfile(formData) {
'use server';
await db.users.update(userId, Object.fromEntries(formData));
}
// ═══════════════════════════════════════════════════════════════
// USE API ROUTES FOR:
// ═══════════════════════════════════════════════════════════════
// External API consumption
// app/api/posts/route.js
export async function GET(request) {
const posts = await db.posts.findMany();
return Response.json(posts);
}
// Webhooks
// app/api/stripe/webhook/route.js
export async function POST(request) {
const event = await stripe.webhooks.constructEvent(...);
// Handle webhook
}
// Long-running streams
// app/api/stream/route.js
export async function GET() {
const stream = new ReadableStream({
async start(controller) {
// Stream data
}
});
return new Response(stream);
}
// OAuth callbacks
// app/api/auth/callback/route.js
export async function GET(request) {
const code = request.nextUrl.searchParams.get('code');
// Exchange code for tokens
}
Framework Implementations
Next.js Implementation
// Next.js specific features
// 1. Redirects
import { redirect } from 'next/navigation';
async function createPost(formData) {
'use server';
const post = await db.posts.create(/*...*/);
redirect(`/posts/${post.id}`); // Navigation after action
}
// 2. Cookies
import { cookies } from 'next/headers';
async function setTheme(formData) {
'use server';
cookies().set('theme', formData.get('theme'));
}
// 3. Headers
import { headers } from 'next/headers';
async function logRequest() {
'use server';
const userAgent = headers().get('user-agent');
const ip = headers().get('x-forwarded-for');
}
// 4. Revalidation
import { revalidatePath, revalidateTag } from 'next/cache';
async function updatePost(id, formData) {
'use server';
await db.posts.update(id, /*...*/);
revalidatePath('/posts');
revalidateTag(`post-${id}`);
}
Framework-Agnostic Patterns
// Core React patterns work across frameworks
// 1. useActionState (React 19)
import { useActionState } from 'react';
// 2. useFormStatus (React DOM)
import { useFormStatus } from 'react-dom';
// 3. useOptimistic (React 19)
import { useOptimistic } from 'react';
// 4. Form action attribute
<form action={serverAction}>
// 5. Button formAction
<button formAction={otherAction}>
Performance Considerations
Optimizing Server Actions
// ═══════════════════════════════════════════════════════════════
// 1. MINIMIZE PAYLOAD SIZE
// ═══════════════════════════════════════════════════════════════
// ❌ Bad: Returning entire object
async function getUser(id) {
'use server';
return await db.users.findById(id); // Includes password hash, etc.
}
// ✅ Good: Return only needed fields
async function getUser(id) {
'use server';
const user = await db.users.findById(id);
return {
id: user.id,
name: user.name,
email: user.email,
};
}
// ═══════════════════════════════════════════════════════════════
// 2. BATCH OPERATIONS
// ═══════════════════════════════════════════════════════════════
// ❌ Bad: Multiple round trips
async function deleteItems(ids) {
for (const id of ids) {
await deleteItem(id); // N requests
}
}
// ✅ Good: Single batched operation
async function deleteItems(ids) {
'use server';
await db.items.deleteMany({ id: { in: ids } }); // 1 request
revalidatePath('/items');
}
// ═══════════════════════════════════════════════════════════════
// 3. AVOID WATERFALLS
// ═══════════════════════════════════════════════════════════════
// ❌ Bad: Sequential operations
async function createProjectWithTasks(data) {
'use server';
const project = await db.projects.create(data.project);
const task1 = await db.tasks.create({ ...data.tasks[0], projectId: project.id });
const task2 = await db.tasks.create({ ...data.tasks[1], projectId: project.id });
}
// ✅ Good: Parallel operations
async function createProjectWithTasks(data) {
'use server';
const project = await db.projects.create(data.project);
await Promise.all(
data.tasks.map(task =>
db.tasks.create({ ...task, projectId: project.id })
)
);
}
// ═══════════════════════════════════════════════════════════════
// 4. USE DATABASE TRANSACTIONS
// ═══════════════════════════════════════════════════════════════
async function transferFunds(from, to, amount) {
'use server';
await db.$transaction(async (tx) => {
await tx.accounts.update(from, { balance: { decrement: amount } });
await tx.accounts.update(to, { balance: { increment: amount } });
});
}
Measuring Performance
// Server-side timing
async function slowAction(formData) {
'use server';
const start = performance.now();
await doWork();
const duration = performance.now() - start;
console.log(`Action took ${duration}ms`);
// Or use OpenTelemetry
const span = tracer.startSpan('slowAction');
try {
await doWork();
} finally {
span.end();
}
}
Debugging Server Actions
Development Tools
// 1. Console logging (appears in server terminal)
async function debugAction(formData) {
'use server';
console.log('Action called with:', Object.fromEntries(formData));
console.log('Headers:', Object.fromEntries(headers()));
console.log('Cookies:', cookies().getAll());
}
// 2. Network inspection
// In browser DevTools, look for:
// - Request to /_rsc?actionId=xxx
// - Content-Type: multipart/form-data
// - Response: text/x-component (Flight format)
// 3. Enable verbose logging (Next.js)
// next.config.js
module.exports = {
logging: {
fetches: {
fullUrl: true,
},
},
};
Common Issues
// ═══════════════════════════════════════════════════════════════
// ISSUE: "Cannot find module 'server-only'"
// ═══════════════════════════════════════════════════════════════
// Cause: Importing server code in client component
// Fix: Move import to server component or action file
// ═══════════════════════════════════════════════════════════════
// ISSUE: "Functions cannot be passed directly to Client Components"
// ═══════════════════════════════════════════════════════════════
// ❌ Wrong
function ServerComponent() {
const handleClick = () => console.log('clicked');
return <ClientComponent onClick={handleClick} />;
}
// ✅ Correct - use Server Action
function ServerComponent() {
async function handleClick() {
'use server';
// Server-side logic
}
return <ClientComponent onClick={handleClick} />;
}
// ═══════════════════════════════════════════════════════════════
// ISSUE: Action not revalidating correctly
// ═══════════════════════════════════════════════════════════════
// Check:
// 1. revalidatePath matches exact route
// 2. revalidateTag matches fetch cache tags
// 3. Response is using cached fetch (not direct db call)
async function updatePost(id, formData) {
'use server';
await db.posts.update(id, /*...*/);
// Be specific with paths
revalidatePath('/posts'); // List page
revalidatePath(`/posts/${id}`); // Detail page
revalidatePath('/posts', 'layout'); // Layout
}
// ═══════════════════════════════════════════════════════════════
// ISSUE: TypeScript errors with FormData
// ═══════════════════════════════════════════════════════════════
// FormData.get() returns FormDataEntryValue | null
// Need to handle types explicitly
async function createUser(formData: FormData) {
'use server';
const name = formData.get('name');
// name is FormDataEntryValue | null
if (typeof name !== 'string') {
throw new Error('Name is required');
}
// Now name is string
await db.users.create({ name });
}
Future Directions
Upcoming Features
┌─────────────────────────────────────────────────────────────────┐
│ Server Actions Roadmap │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CURRENT (React 19 / Next.js 14+) │
│ ✓ Form actions │
│ ✓ useActionState │
│ ✓ useFormStatus │
│ ✓ useOptimistic │
│ ✓ Progressive enhancement │
│ ✓ Bound arguments (closures) │
│ │
│ EXPERIMENTAL │
│ ○ Streaming action responses │
│ ○ Action middleware (official) │
│ ○ Retry policies │
│ ○ Offline support / sync │
│ │
│ FUTURE POSSIBILITIES │
│ ○ Real-time subscriptions via actions │
│ ○ Distributed actions (edge + origin) │
│ ○ Action analytics / observability │
│ ○ Schema-based validation integration │
│ │
└─────────────────────────────────────────────────────────────────┘
Summary
React Server Actions represent a fundamental shift in React application architecture, bringing server-side mutations directly into the component model.
┌─────────────────────────────────────────────────────────────────┐
│ Server Actions Key Takeaways │
├─────────────────────────────────────────────────────────────────┤
│ │
│ WHAT THEY ARE │
│ • Async functions that execute on the server │
│ • Marked with 'use server' directive │
│ • Called via form actions or direct invocation │
│ │
│ HOW THEY WORK │
│ • Compiled to unique endpoints (action IDs) │
│ • Arguments serialized via Flight protocol │
│ • Responses streamed back to update UI │
│ • Automatic transition wrapping │
│ │
│ KEY FEATURES │
│ • Progressive enhancement (works without JS) │
│ • Type-safe end-to-end │
│ • Integrated with cache revalidation │
│ • Optimistic updates via useOptimistic │
│ • Form state via useActionState │
│ │
│ SECURITY │
│ • Server code never in client bundle │
│ • Bound arguments encrypted │
│ • Always validate user input │
│ • Always check authorization │
│ │
│ BEST PRACTICES │
│ • Use for mutations, not data fetching │
│ • Return minimal data │
│ • Handle errors gracefully │
│ • Implement rate limiting │
│ • Use Zod/Valibot for validation │
│ │
└─────────────────────────────────────────────────────────────────┘
References
- React Documentation: Server Actions
- Next.js Documentation: Server Actions
- RFC: React Server Components
- React Flight Protocol
Last Updated: 2024 | React 19 / Next.js 14+
What did you think?