React

React Server Actions Complete Guide (2026)

Master Server Actions in Next.js 15 and React 19: forms, mutations, useActionState, Zod validation, file uploads, revalidation, and when to skip API routes entirely.

May 8, 202612 min read
Share:
React Server Actions Complete Guide (2026)

Server Actions are one of the biggest shifts in how React apps handle data mutations. Instead of writing a separate API route, validating input, handling CORS, and then calling it from the client, you write a single async function that runs on the server — and call it directly from your component.

This guide covers everything: the mental model, the syntax, real patterns with validation and error handling, file uploads, revalidation, and the cases where you should still use an API route.

The Mental Model

A Server Action is an async function marked with 'use server'. When a Client Component calls it, Next.js serializes the arguments, sends them to the server, runs the function, and returns the result — all over an encrypted HTTP request that Next.js manages for you.

Client Component calls serverAction(data)
     ↓
Next.js serializes args → POST /action-endpoint
     ↓
Server runs the function (DB access, auth, business logic)
     ↓
Returns serializable result → Client receives it

You get:

  • No API route files to create, export, or maintain
  • Type-safe function calls (TypeScript works end-to-end)
  • Automatic CSRF protection — Next.js handles it
  • Co-location — action lives next to the UI that uses it

Defining Server Actions

// app/actions/users.ts
'use server'
 
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
 
export async function createUser(formData: FormData) {
  const name = formData.get('name') as string
  const email = formData.get('email') as string
 
  await db.user.create({ data: { name, email } })
  revalidatePath('/users')
}

Inline in a Server Component

// app/users/page.tsx
export default function UsersPage() {
  async function createUser(formData: FormData) {
    'use server'
    const name = formData.get('name') as string
    await db.user.create({ data: { name } })
    revalidatePath('/users')
  }
 
  return (
    <form action={createUser}>
      <input name="name" />
      <button type="submit">Add user</button>
    </form>
  )
}

The 'use server' directive inside the function body is the inline version. The file-level directive (at the top) marks every export in that file as a Server Action.

Using Server Actions with Forms

The simplest pattern: pass the action directly to <form action={...}>. This works even with JavaScript disabled.

// app/users/new/page.tsx
import { createUser } from '@/app/actions/users'
 
export default function NewUserPage() {
  return (
    <form action={createUser}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit">Create</button>
    </form>
  )
}

useActionState — Pending, Errors, and State

useActionState (React 19, available in Next.js 15) gives you pending state and the ability to return state from your action back to the component.

The action with return value

// app/actions/users.ts
'use server'
 
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
 
const schema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email'),
})
 
type ActionState = {
  errors?: Record<string, string[]>
  success?: boolean
  message?: string
}
 
export async function createUser(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
  }
 
  const result = schema.safeParse(raw)
 
  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
    }
  }
 
  try {
    await db.user.create({ data: result.data })
    revalidatePath('/users')
    return { success: true, message: 'User created!' }
  } catch (error) {
    return { message: 'Database error. Please try again.' }
  }
}

The component with useActionState

// app/users/new/page.tsx (or a Client Component)
'use client'
 
import { useActionState } from 'react'
import { createUser } from '@/app/actions/users'
 
export function CreateUserForm() {
  const [state, action, isPending] = useActionState(createUser, {})
 
  return (
    <form action={action}>
      <div>
        <input name="name" placeholder="Name" disabled={isPending} />
        {state.errors?.name && (
          <p className="text-red-500">{state.errors.name[0]}</p>
        )}
      </div>
      <div>
        <input name="email" type="email" placeholder="Email" disabled={isPending} />
        {state.errors?.email && (
          <p className="text-red-500">{state.errors.email[0]}</p>
        )}
      </div>
      {state.message && (
        <p className={state.success ? 'text-green-500' : 'text-red-500'}>
          {state.message}
        </p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create user'}
      </button>
    </form>
  )
}

useActionState signature:

const [state, formAction, isPending] = useActionState(
  action,        // your Server Action (prevState, formData) => state
  initialState,  // the initial state before first submission
  permalink?     // optional URL for progressive enhancement
)

useFormStatus — Submit Button Pending State

useFormStatus reads the status of the parent <form>. It's useful for extracting the submit button into its own component:

// components/submit-button.tsx
'use client'
 
import { useFormStatus } from 'react-dom'
 
export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus()
 
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : children}
    </button>
  )
}
<form action={createUser}>
  <input name="name" />
  <SubmitButton>Create user</SubmitButton>
</form>

useFormStatus must be used in a component that's inside the <form>, not in the component that renders the form.

Calling Server Actions from Event Handlers

Server Actions don't have to be used with <form>. You can call them programmatically with startTransition:

'use client'
 
import { useTransition } from 'react'
import { deleteUser } from '@/app/actions/users'
 
export function DeleteButton({ userId }: { userId: string }) {
  const [isPending, startTransition] = useTransition()
 
  return (
    <button
      onClick={() => startTransition(() => deleteUser(userId))}
      disabled={isPending}
    >
      {isPending ? 'Deleting...' : 'Delete'}
    </button>
  )
}

The Server Action:

// app/actions/users.ts
'use server'
 
export async function deleteUser(userId: string) {
  await db.user.delete({ where: { id: userId } })
  revalidatePath('/users')
}

Optimistic Updates with useOptimistic

For lists where you want instant UI feedback before the server responds:

'use client'
 
import { useOptimistic, useTransition } from 'react'
import { toggleTodo } from '@/app/actions/todos'
 
type Todo = { id: string; text: string; done: boolean }
 
export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, setOptimistic] = useOptimistic(
    todos,
    (state, todoId: string) =>
      state.map(t => (t.id === todoId ? { ...t, done: !t.done } : t))
  )
  const [, startTransition] = useTransition()
 
  function handleToggle(todoId: string) {
    startTransition(async () => {
      setOptimistic(todoId)       // instant UI update
      await toggleTodo(todoId)    // actual server update
    })
  }
 
  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li key={todo.id} onClick={() => handleToggle(todo.id)}>
          <span style={{ opacity: todo.done ? 0.5 : 1 }}>{todo.text}</span>
        </li>
      ))}
    </ul>
  )
}

File Uploads

Server Actions handle FormData natively, including files:

// app/actions/uploads.ts
'use server'
 
export async function uploadAvatar(formData: FormData) {
  const file = formData.get('avatar') as File
 
  if (!file || file.size === 0) {
    return { error: 'No file provided' }
  }
 
  if (file.size > 5 * 1024 * 1024) {
    return { error: 'File too large (max 5MB)' }
  }
 
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    return { error: 'Invalid file type' }
  }
 
  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)
 
  // Upload to your storage (S3, Supabase Storage, Cloudflare R2, etc.)
  const url = await uploadToStorage(buffer, file.name, file.type)
 
  await db.user.update({
    where: { id: getCurrentUserId() },
    data: { avatarUrl: url },
  })
 
  return { url }
}
<form action={uploadAvatar} encType="multipart/form-data">
  <input type="file" name="avatar" accept="image/*" />
  <SubmitButton>Upload</SubmitButton>
</form>

Revalidation

After a mutation, you need to tell Next.js to refresh the data. Two options:

revalidatePath — refresh a specific page

import { revalidatePath } from 'next/cache'
 
// Revalidate the entire /users segment
revalidatePath('/users')
 
// Revalidate a specific dynamic route
revalidatePath(`/users/${userId}`)
 
// Revalidate all pages (use sparingly)
revalidatePath('/', 'layout')

revalidateTag — refresh by cache tag

import { revalidateTag } from 'next/cache'
 
// In your action
revalidateTag('users')
revalidateTag(`user-${userId}`)

Tag your fetches:

const users = await fetch('/api/users', {
  next: { tags: ['users'] }
})

Or with Drizzle/Prisma, use unstable_cache:

import { unstable_cache } from 'next/cache'
 
const getUsers = unstable_cache(
  async () => db.user.findMany(),
  ['users'],
  { tags: ['users'] }
)

Redirects from Server Actions

import { redirect } from 'next/navigation'
 
export async function createPost(formData: FormData) {
  'use server'
 
  const post = await db.post.create({
    data: { title: formData.get('title') as string }
  })
 
  redirect(`/posts/${post.id}`)  // must be called outside try/catch
}

redirect throws internally — don't wrap it in try/catch or it won't work.

Authentication and Authorization in Server Actions

Always check auth before running mutations. Never trust that the client is who they say they are:

// app/actions/posts.ts
'use server'
 
import { auth } from '@/lib/auth'
 
export async function deletePost(postId: string) {
  const session = await auth()
 
  if (!session?.user) {
    throw new Error('Unauthorized')
  }
 
  // Verify ownership — don't just trust the postId
  const post = await db.post.findUnique({
    where: { id: postId },
    select: { authorId: true },
  })
 
  if (!post || post.authorId !== session.user.id) {
    throw new Error('Forbidden')
  }
 
  await db.post.delete({ where: { id: postId } })
  revalidatePath('/posts')
}

Cookies and Headers

import { cookies, headers } from 'next/headers'
 
export async function setPreference(theme: 'light' | 'dark') {
  'use server'
 
  const cookieStore = await cookies()
  cookieStore.set('theme', theme, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 365,
  })
}
 
export async function getUserAgent() {
  'use server'
  const headersList = await headers()
  return headersList.get('user-agent')
}

Passing Additional Data to Actions

When you need to pass data that isn't in the form (like an ID from a list), use .bind():

import { deleteUser } from '@/app/actions/users'
 
export function UserRow({ user }: { user: User }) {
  const deleteUserWithId = deleteUser.bind(null, user.id)
 
  return (
    <form action={deleteUserWithId}>
      <button type="submit">Delete {user.name}</button>
    </form>
  )
}

The action:

export async function deleteUser(userId: string, formData: FormData) {
  'use server'
  // userId is bound, formData comes from the form
  await db.user.delete({ where: { id: userId } })
}

When NOT to Use Server Actions

Server Actions are for mutations called from your own UI. Use a traditional API route when:

ScenarioUse
Webhook from Stripe/GitHubAPI route (POST /api/webhook)
OAuth callbackAPI route
Consumed by a mobile appAPI route
GET requestsNot applicable (actions are POST)
Third-party service calling your endpointAPI route
Public APIAPI route

Server Actions are not public endpoints — they're tied to your Next.js app's internal routing. Treat them as private function calls, not HTTP APIs.

Common Mistakes

Mistake 1: Forgetting 'use client' on components that use hooks

useActionState and useFormStatus are client-side hooks. Any component using them needs 'use client' at the top.

Mistake 2: Calling redirect() inside try/catch

// ❌ Wrong — redirect won't work
try {
  await db.post.create(...)
  redirect('/posts')  // this throws internally, gets caught
} catch (e) {
  return { error: 'something' }
}
 
// ✅ Correct
await db.post.create(...)
redirect('/posts')  // outside try/catch

Mistake 3: Not validating input on the server

The client sends data to the server — validate it there. Don't rely on HTML5 required or client-side validation alone.

Mistake 4: Heavy computation in Server Actions

Server Actions run on each request — they're not background jobs. For heavy work (image processing, sending emails, long DB queries), use a job queue and return immediately.

Comparison with API Routes

Server ActionAPI Route
DefinitionFunction with 'use server'export async function POST() in route.ts
Calling from clientDirect import + callfetch('/api/...')
Type safetyAutomatic (shared types)Manual or via tRPC/Zod
Form integrationNative (<form action>)Requires JS always
Use for webhooksNoYes
Works without JSYes (forms)No

If you're building a full stack app with Server Actions, pair them with Drizzle ORM for type-safe database access and Zustand for client-side state when you need UI state that doesn't need to be persisted. For authentication that integrates cleanly with Server Actions, see the Next.js auth guide.

#react#next-js#server-actions#typescript#forms
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.