| |

React 91 ⚛️ Server Actions and Server-Side Mutations

⏱️ Estimated read: 17 min | |

Server Actions are the mechanism that lets a Client Component call an async function that executes on the server. The 'use server' directive marks a function or module as server-executable; the bundler replaces the implementation in the client bundle with a reference, and when the client calls that reference, React sends a POST request to the server, runs the function, and returns the result . The function runs inside the server environment, with access to the database, the filesystem, and environment variables that never reach the browser.

The model is not an RPC framework bolted onto React. It is integrated with React’s form handling, pending states, and cache invalidation. A Server Action can be passed to a <form action={...}>, composed with useActionState for validation errors and pending indicators, and paired with revalidatePath or revalidateTag so the mutation and the refreshed UI arrive in a single round trip . The integration is what distinguishes Server Actions from a hand-rolled fetch('/api/mutate') call: the framework handles the serialization, the CSRF check, the re-render, and the optimistic update plumbing.

This chapter covers the 'use server' directive and its placement rules, the form integration and useActionState hook, the cache revalidation and redirect patterns, the security boundary that treats every action as a public endpoint, and the sequential dispatch constraint that prevents client-side parallelization. It assumes familiarity with React Server Components and the 'use client' directive.

Key point: A Server Action is a public POST endpoint. The 'use server' directive hides the implementation from the client bundle, but it does not hide the route. Every action must authenticate, authorize, and validate its inputs as if it were an API route, because that is what it is.


Why Server Actions exist as a mutation primitive

API routes require boilerplate that Server Actions eliminate. A traditional mutation needs a route handler, a fetch call with the correct method and headers, JSON serialization on both sides, error handling for non-2xx responses, and a separate mechanism for revalidating cached data. A Server Action collapses this into a function call: the client invokes the function, the framework serializes the arguments, the server runs the function, and the return value comes back . The route handler is still there, but the framework generates and manages it.

Form submissions are the primary use case. React 19 extends the <form> element to accept a Server Action as its action prop. When the form submits, the action receives the FormData object automatically. The framework handles the request, and if the action calls revalidatePath, the response includes both the action’s return value and the re-rendered UI for the current route . This single-round-trip behavior is the performance argument: no follow-up fetch is needed to see the updated data.

Progressive enhancement is built in. A form with a Server Action works before hydration. The browser submits the form to the server, the action runs, and the response is HTML. When JavaScript loads, React hydrates the form and subsequent submissions become client-side calls. useActionState can replay submissions entered before hydration finishes, so the user is not blocked by the JavaScript bundle .

The trade-off. Server Actions are coupled to the framework that implements them. The 'use server' directive is a React feature, but the network transport, the CSRF protection, and the cache invalidation functions (revalidatePath, revalidateTag) are Next.js-specific. A Server Action written for Next.js does not run unchanged in another React framework without an equivalent implementation. The React documentation explicitly states that the underlying bundler APIs are not semver-stable and recommends pinning the React version .


a. The 'use server' directive and the function signature

The directive must be at the very top of the function body or the module file, above any imports. It uses single or double quotes, not backticks. The function must be async, because the client-server call is always asynchronous . There are two placement patterns: inline in a Server Component, or at the module level in a separate file.

Inline actions are defined inside a Server Component and passed to a Client Component as a prop. The directive is the first statement in the function body.

// Server Component (no 'use client' directive).
import Button from './Button';
import { db } from '@/lib/db';

export default function EmptyNote() {
  async function createNoteAction() {
    'use server';
    await db.notes.create();
  }

  // The reference is passed to the Client Component.
  return <Button onClick={createNoteAction} />;
}

Module-level actions are defined in a separate file with the directive at the top. Client Components import the function directly. The bundler creates a reference in the client bundle that points to the server implementation .

// app/actions.ts
'use server';

import { db } from '@/lib/db';

export async function createNoteAction() {
  await db.notes.create();
}

The function signature changes when used with a form. A form action receives FormData as its only argument. When composed with useActionState, the signature becomes (prevState, formData), with the previous state as the first argument .

// Form action signature.
export async function createInvoice(formData: FormData) { ... }

// useActionState signature.
export async function subscribe(prevState: FormState, formData: FormData) { ... }

Arguments passed to a Server Action must be serializable. Class instances, non-global symbols, and objects with null prototypes cannot be passed . The framework serializes the arguments into the request body, and the server deserializes them before the function runs.


b. Form integration, validation, and pending states

The <form action={...}> integration is the canonical usage. The action receives FormData, which is the browser’s native form data object. Extraction uses formData.get('fieldName') or Object.fromEntries(formData) for the full set of fields .

'use server';

import { z } from 'zod';
import { db } from '@/lib/db';

const schema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
});

export async function createPost(formData: FormData) {
  const parsed = schema.safeParse({
    title: formData.get('title'),
    body: formData.get('body'),
  });

  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }

  await db.post.create({ data: parsed.data });
}

The useActionState hook connects the action’s return value to the component’s state. It returns a tuple of [state, formAction, isPending]. The state is the last value the action returned. The formAction is a wrapped function to pass to the form’s action prop. The isPending boolean is true while the action executes .

'use client';

import { useActionState } from 'react';
import { createPost } from './actions';

const initialState = { errors: {} };

export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState);

  return (
    <form action={formAction}>
      <input name="title" />
      {state.errors?.title && <p>{state.errors.title[0]}</p>}
      <button disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
}

The useFormStatus hook is an alternative for pending states. It must be called from a component rendered inside the form, and it returns a pending boolean. The advantage is that it does not require the form component to be a Client Component; only the submit button component needs 'use client' .


c. Revalidation, redirects, and the single-round-trip response

A Server Action that mutates data must invalidate the cached data that depends on it. Next.js provides revalidatePath and revalidateTag. When an action calls either function, the response includes both the action’s return value and a re-rendered RSC payload for the current route. The client commits the new tree without a follow-up fetch .

'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
  await db.post.create({
    data: { title: String(formData.get('title')) },
  });

  // Invalidate the cached list page.
  // The response includes the re-rendered page.
  revalidatePath('/posts');
}

redirect is a control-flow exception. When an action calls redirect('/posts'), the response navigates the router and streams the destination’s RSC payload. Any code after the redirect call does not run. If the destination needs fresh data, revalidatePath must be called before the redirect .

'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
  redirect('/posts'); // Code after this does not run.
}

Cookie mutations inside an action also trigger a re-render. Setting or deleting a cookie via cookies().set() or cookies().delete() causes Next.js to re-render the current page and its layouts on the server, so the UI reflects the new cookie value without a separate request .


d. The security boundary and sequential dispatch

The 'use server' directive hides the function’s implementation from the client bundle, but it does not hide the route. At build time, the compiler replaces the function in the client bundle with a reference containing an action ID and a dispatcher. The route is reachable to anyone who can send the same POST request . The framework enforces a CSRF check comparing the Origin header to the Host header, and it limits the request body to 1MB by default. These are baseline protections, not a substitute for application-level checks.

Every action must authenticate and authorize. Rendering a form only on an authenticated page is not a security boundary, because the action can be invoked directly without the UI. The Next.js documentation is explicit: “Treat every action as an untrusted entry point” . A correct action checks the session, validates the input, and verifies that the authenticated user is allowed to perform the mutation.

'use server';

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { z } from 'zod';

const schema = z.object({ id: z.string().uuid() });

export async function deletePost(formData: FormData) {
  // 1. Authenticate.
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  // 2. Validate input.
  const parsed = schema.safeParse({ id: formData.get('id') });
  if (!parsed.success) throw new Error('Invalid input');

  // 3. Authorize: the user must own the post.
  const post = await db.post.findUnique({ where: { id: parsed.data.id } });
  if (post?.authorId !== session.user.id) throw new Error('Forbidden');

  // 4. Mutate.
  await db.post.delete({ where: { id: parsed.data.id } });
}

Sequential dispatch is a client-side constraint. Next.js dispatches Server Actions one at a time per client. If a user triggers three actions in quick succession, the second waits for the first to finish, and the third waits for the second. This keeps the re-rendered server tree consistent with the action result that produced it. Promise.all does not parallelize Server Actions from the client. If parallel work is needed, it must happen inside a single action, in a Server Component’s data fetching, or in a Route Handler .


Complete Example Session

// ============================================
// PART 1: THE ACTION MODULE
// ============================================
// app/posts/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { z } from 'zod';

const postSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
});

// ============================================
// PART 2: CREATE ACTION WITH VALIDATION
// ============================================
export async function createPost(prevState: any, formData: FormData) {
  // Authenticate inside the action.
  const session = await auth();
  if (!session?.user) {
    return { errors: { auth: ['You must be logged in.'] } };
  }

  // Validate input.
  const parsed = postSchema.safeParse({
    title: formData.get('title'),
    body: formData.get('body'),
  });

  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }

  // Mutate.
  await db.post.create({
    data: { ...parsed.data, authorId: session.user.id },
  });

  // Invalidate and redirect.
  revalidatePath('/posts');
  redirect('/posts');
}

// ============================================
// PART 3: DELETE ACTION WITH AUTHORIZATION
// ============================================
export async function deletePost(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const id = String(formData.get('id'));
  const post = await db.post.findUnique({ where: { id } });

  if (post?.authorId !== session.user.id) {
    throw new Error('Forbidden');
  }

  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

// ============================================
// PART 4: THE FORM COMPONENT
// ============================================
// app/posts/new/page.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '../actions';

const initialState = { errors: {} };

export default function NewPostPage() {
  const [state, formAction, isPending] = useActionState(
    createPost,
    initialState
  );

  return (
    <form action={formAction}>
      <input name="title" placeholder="Title" />
      {state.errors?.title && <p>{state.errors.title[0]}</p>}

      <textarea name="body" placeholder="Body" />
      {state.errors?.body && <p>{state.errors.body[0]}</p>}

      <button disabled={isPending}>
        {isPending ? 'Publishing...' : 'Publish'}
      </button>
    </form>
  );
}

// ============================================
// PART 5: BINDING ADDITIONAL ARGUMENTS
// ============================================
// Pass userId outside the form fields.
// app/client.tsx
'use client';
import { updateUser } from './actions';

export function UserProfile({ userId }: { userId: string }) {
  const updateUserWithId = updateUser.bind(null, userId);
  return (
    <form action={updateUserWithId}>
      <input name="name" />
      <button type="submit">Update</button>
    </form>
  );
}

// ============================================
// PART 6: PENDING STATE WITH useFormStatus
// ============================================
// app/submit-button.tsx
'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>
      {pending ? 'Saving...' : 'Save'}
    </button>
  );
}

// ============================================
// PART 7: REVALIDATION TAGS
// ============================================
// Tag cached data, revalidate by tag.
import { revalidateTag } from 'next/cache';

export async function updatePost(formData: FormData) {
  await db.post.update({ ... });
  revalidateTag('posts');
}

// ============================================
// PART 8: OPTIMISTIC UPDATES
// ============================================
// app/comments.tsx
'use client';
import { useOptimistic } from 'react';
import { addComment } from './actions';

export function CommentList({ comments }: { comments: Comment[] }) {
  const [optimistic, addOptimistic] = useOptimistic(
    comments,
    (state, newComment: string) => [...state, { text: newComment, sending: true }]
  );

  async function handleSubmit(formData: FormData) {
    const text = String(formData.get('text'));
    addOptimistic(text);
    await addComment(formData);
  }

  return (
    <>
      {optimistic.map((c, i) => <p key={i}>{c.text}</p>)}
      <form action={handleSubmit}>
        <input name="text" />
        <button>Send</button>
      </form>
    </>
  );
}

// ============================================
// PART 9: SEQUENTIAL DISPATCH
// ============================================
// Actions dispatch one at a time per client.
// Do NOT use Promise.all on the client:
// await Promise.all([actionA(), actionB()]); // Not parallel.

// For parallel work, do it inside one action:
export async function batchAction() {
  'use server';
  const [a, b] = await Promise.all([db.a(), db.b()]);
}

// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// 1. Define action with 'use server' at top.
// 2. Authenticate, authorize, validate.
// 3. Mutate the database.
// 4. Revalidate cache path or tag.
// 5. Redirect if needed (after revalidation).
// 6. Client: form action={formAction}.
// 7. useActionState for errors and pending.
// 8. useOptimistic for immediate UI feedback.

Quick Reference

Directive Placement

PatternPlacementImport
Module-levelTop of fileimport { fn } from './actions'
InlineFirst line of function bodyPass as prop to Client Component

Hook Comparison

HookImportReturnsUse For
useActionStatereact[state, formAction, isPending]Validation errors, pending
useFormStatusreact-dom{ pending }Submit button inside form
useOptimisticreact[optimisticState, addOptimistic]Immediate UI feedback

Revalidation Functions

FunctionEffectRe-render in Response
revalidatePath(path)Invalidate path cacheYes
revalidateTag(tag)Invalidate tag cacheYes (except stale-while-revalidate)
refresh()Refetch current RSC payloadYes
redirect(path)Navigate and stream payloadYes (throws)

Security Checklist

CheckPurpose
auth() inside actionVerify session
Input validation (Zod)Reject malformed data
Ownership checkPrevent cross-user mutations
No sensitive returnsAction returns serialize to client

Best Practices

✅ Do This:

// Put 'use server' at the top of the file.                   // ✅
'use server';

// Authenticate inside every action.                          // ✅
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');

// Validate with Zod before mutation.                         // ✅
const parsed = schema.safeParse({ ... });

// Revalidate before redirect.                                // ✅
revalidatePath('/posts');
redirect('/posts');

// Use bind for additional arguments.                         // ✅
const action = updateUser.bind(null, userId);

❌ Don’t Do This:

// Rely on page-level auth as the only check.                 // ❌
// Action can be invoked directly without the UI.

// Return raw database records to the client.                // ❌
return await db.user.findMany(); // Leaks sensitive fields.

// Use Promise.all to parallelize actions.                    // ❌
// Client dispatches sequentially; not parallel.

// Skip validation because "the form has required fields."    // ❌
// FormData is fully client-controlled.

Common Pitfalls

PitfallWhy It HappensFix
Action runs unauthenticatedRelied on page-level authauth() inside action
Sensitive data in responseReturned raw DB rowShape return to UI needs
Actions not parallelSequential dispatch constraintBatch inside one action
redirect code after does not runThrows control-flow exceptionrevalidatePath before redirect
Form fields reset on successReact resets form after actionReturn values; use defaultValue
Action ID exposedRoute is publicFramework CSRF + app auth

Real-World Examples

1. Module-Level Action

'use server';
export async function createNote() { await db.notes.create(); }

2. Form Action Signature

export async function createInvoice(formData: FormData) { ... }

3. useActionState Signature

export async function subscribe(prevState: FormState, formData: FormData) { ... }

4. Zod Validation

const parsed = schema.safeParse({ email: formData.get('email') });
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors };

5. Ownership Check

if (post.authorId !== session.user.id) throw new Error('Forbidden');

6. Revalidate Path

revalidatePath('/posts');

7. Redirect After Revalidate

revalidatePath('/posts'); redirect('/posts');

8. Bind Additional Args

const action = updateUser.bind(null, userId);

9. Pending Button

const { pending } = useFormStatus();

10. Optimistic Update

addOptimistic(text); await addComment(formData);

Visual: Server Action Flow

┌──────────────────────────────────────────────┐
│  CLIENT COMPONENT                            │
│                                              │
│  <form action={formAction}>                  │
│       │                                      │
│       │ formAction is a reference            │
│       ▼                                      │
│  POST request to server                      │
│       │                                      │
│       ├── Origin check (CSRF)                │
│       ├── Body size limit (1MB)              │
│       └── Action ID dispatcher               │
│       │                                      │
│       ▼                                      │
│  SERVER                                      │
│                                              │
│  1. auth() → session                         │
│  2. validate(input)                          │
│  3. db.mutate()                              │
│  4. revalidatePath()                         │
│       │                                      │
│       ▼                                      │
│  Response: { returnValue, RSC payload }      │
│       │                                      │
│       ▼                                      │
│  Client commits new tree. No follow-up fetch.│
│                                              │
└──────────────────────────────────────────────┘

Security Boundary

┌──────────────────────────────────────────────┐
│  PUBLIC ENDPOINT                             │
│                                              │
│  'use server' hides implementation,          │
│  not the route.                              │
│                                              │
│  Anyone can POST to the action route.        │
│                                              │
│  Framework protections:                      │
│  - CSRF Origin check                         │
│  - Body size limit                           │
│  - Encrypted action IDs                      │
│                                              │
│  App-level checks (mandatory):               │
│  - Authenticate inside action                │
│  - Authorize the specific mutation           │
│  - Validate all inputs                       │
│  - Shape return values                       │
│                                              │
└──────────────────────────────────────────────┘

Sequential Dispatch

┌──────────────────────────────────────────────┐
│  CLIENT DISPATCHER                           │
│                                              │
│  Action A ──► finishes                       │
│       │                                      │
│       ▼                                      │
│  Action B waits for A                        │
│       │                                      │
│       ▼                                      │
│  Action C waits for B                        │
│                                              │
│  Promise.all does NOT parallelize.           │
│                                              │
│  For parallel work:                          │
│  - Inside one action: Promise.all(db...)     │
│  - Server Component: parallel fetch          │
│  - Route Handler for non-mutations           │
│                                              │
└──────────────────────────────────────────────┘

Single Round Trip

┌──────────────────────────────────────────────┐
│  WITHOUT revalidatePath                      │
│                                              │
│  Action returns value.                       │
│  UI does NOT re-render current route.        │
│  Client must fetch updated data.             │
│                                              │
│  WITH revalidatePath / redirect / cookie     │
│                                              │
│  Action runs.                                │
│  Route re-renders server-side.               │
│  Response = return value + RSC payload.      │
│  Client commits new tree.                    │
│  No follow-up fetch.                         │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Directive'use server' at top of function or file
Function typeMust be async
Form signature(formData: FormData)
useActionState signature(prevState, formData)
State hookuseActionState from react
Pending hookuseFormStatus from react-dom
Optimistic hookuseOptimistic from react
RevalidationrevalidatePath, revalidateTag
Redirectredirect (throws; code after does not run)
DispatchSequential per client; not parallel
SecurityAuthenticate, authorize, validate inside every action

Key takeaways:

  • A Server Action is a public POST endpoint. The 'use server' directive hides the implementation from the client bundle, but the route is reachable. Every action must authenticate, authorize, and validate its inputs as if it were an API route .
  • Form integration is the primary pattern. The action receives FormData automatically. useActionState connects the return value to component state, and useFormStatus provides a pending boolean for the submit button .
  • Revalidation produces a single round trip. When an action calls revalidatePath, revalidateTag, or redirect, the response includes both the return value and the re-rendered RSC payload. The client does not need a follow-up fetch .
  • redirect throws. Code after redirect does not run. Place revalidatePath before redirect if the destination needs fresh data .
  • Client dispatch is sequential. Server Actions run one at a time per client. Promise.all does not parallelize them. Parallel work must happen inside a single action or in a Server Component .
  • Validation is mandatory. FormData is fully client-controlled. Zod or a similar schema library validates the input before it reaches the database. The type system erases at runtime; the schema does not .
  • Optimistic updates improve perceived performance. useOptimistic shows the expected result immediately while the action runs. If the action fails, the optimistic update is rolled back .

Remember: Server Actions collapse the mutation stack—route handler, fetch call, serialization, error handling, cache invalidation—into a single function annotated with 'use server'. The framework generates the POST route, handles the CSRF check, serializes the arguments, and re-renders the page when the action invalidates cached data. The convenience is real, but it does not change the security model: the action is a public endpoint, and the only thing that stands between an attacker and your database is the authentication, authorization, and validation code you write inside it. Treat every action as if it were a route handler, because it is.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!