404 Not Found

404 Not Found


nginx

Server Actions Basics

Server Actions are Next.js's "superpower"—they allow the browser to call server-side functions directly, without having to manually build API endpoints.

1. What You'll Learn


2. A True Story of a Full-Stack Developer

(1) Pain Point: It takes 80 lines of code to submit a "simple" form

Bob is adding a "Create Project" feature to TaskFlow. The traditional approach requires:

Bob sighed, "I just want to submit a form."

(2) The Server Actions Solution

Process forms directly using a single 'use server' function—zero client-side JS overhead, zero API routes.

TSX
// app/projects/page.tsx
export default function ProjectsPage() {
  return (
    <form action={async (formData: FormData) => {
      'use server'
      const name = formData.get('name') as string
      await db.project.create({ data: { name } })
      revalidatePath('/projects')
    }}>
      <input name="name" required placeholder="Project name" />
      <button type="submit">Create Project</button>
    </form>
  )
}

(3) Revenue

Dimension Traditional API Route Server Actions
Number of lines of code 80 lines 15 lines
API Route File ✅ Additional files required Not required
JavaScript Dependencies ✅ Required Progressive Enhancement
CSRF Protection ❌ Manual Built-in
Loading Status ✅ Manual state required useActionState
Type Safety ❌ Loose Full TS Types

3. Core Workflow of Server Actions

100%
sequenceDiagram
    participant Browser as Browser
    participant RSC as RSC Payload
    participant SA as Server Action
    participant DB as Database

    Browser->>SA: <form action={action}> Submit
    SA->>SA: Compile-time flags 'use server'
    SA->>DB: Direct Database Write
    DB-->>SA: Success
    SA->>SA: revalidatePath() / revalidateTag()
    SA-->>Browser: Back RSC Payload (new UI)
    Note over Browser: No need to refresh the entire page
Participant Role Description
Browser Form submitter Called via <form action> or JS
RSC Payload Transmission Protocol Serialization Bridge Between Browser and Server
Server Action Handler Asynchronous function tagged with 'use server'
Database Data Persistence Prisma / Drizzle or External API

4. Three Ways to Define Server Actions

Server Action has two definition locations and one React 19 API integration:

Method Location Use Case Code Example
Single file app/actions.ts Shared across multiple pages export async function createProject(...)
Inline Within a component <form action={async ...}> Simple operation 'use server' Within a function
useActionState Client Component Requires complex state management useActionState(action, initialState)
TS
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'

export async function createProject(formData: FormData) {
  const name = formData.get('name') as string
  const description = formData.get('description') as string

  await db.project.create({
    data: { name, description }
  })

  revalidatePath('/projects')
}

export async function deleteProject(id: number) {
  await db.project.delete({ where: { id } })
  revalidatePath('/projects')
}
TSX
// app/projects/page.tsx
import { createProject } from '@/app/actions'

export default function ProjectsPage() {
  return (
    <form action={createProject}>
      <input name="name" required />
      <input name="description" />
      <button type="submit">Create</button>
    </form>
  )
}

(2) Inline Mode (Simple Scenarios)

TSX
// app/inline-demo/page.tsx — Inline Server Action
export default function InlineDemoPage() {
  return (
    <form action={async (formData: FormData) => {
      'use server'
      const message = formData.get('message') as string
      console.log('Received:', message)
      // Direct Processing,No additional documents required
    }}>
      <input name="message" required />
      <button type="submit">Send</button>
    </form>
  )
}
TSX
// app/todos/use-action-state.tsx
'use client'
import { useActionState } from 'react'

async function addTodo(prevState: string[], formData: FormData) {
  'use server'
  const todo = formData.get('todo') as string
  await db.todo.create({ data: { title: todo } })
  revalidateTag('todos')
  return [...prevState, todo]
}

export default function TodoForm() {
  const [todos, formAction, isPending] = useActionState(addTodo, [])

  return (
    <form action={formAction}>
      <input name="todo" required disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Adding...' : 'Add Todo'}
      </button>
      <ul>{todos.map((t, i) => <li key={i}>{t}</li>)}</ul>
    </form>
  )
}

▶ Example: Comparison of Three Definition Methods (Difficulty: ⭐)

Output:

TEXT
A form with fields: todo.
On submit, the server action processes the data and invalidates the relevant cache tags.
TSX
// app/actions-comparison/page.tsx
import { createTodoInline, createTodoAction } from './actions'

export default function ActionsComparisonPage() {
  return (
    <div>
      <h1>Server Actions — 3 Ways</h1>

      {/* Method 1:Separate File */}
      <section>
        <h2>1. Separate file</h2>
        <form action={createTodoAction}>
          <input name="title" required />
          <button type="submit">Create</button>
        </form>
      </section>

      {/* Method 2:Inline */}
      <section>
        <h2>2. Inline</h2>
        <form action={async (formData: FormData) => {
          'use server'
          const title = formData.get('title') as string
          await fetch('https://jsonplaceholder.typicode.com/todos', {
            method: 'POST', body: JSON.stringify({ title, completed: false })
          })
        }}>
          <input name="title" required />
          <button type="submit">Create</button>
        </form>
      </section>

      {/* Method 3:Client Component Call */}
      <section>
        <h2>3. UseActionState (see below)</h2>
        <TodoFormWithState />
      </section>
    </div>
  )
}

Output:

TEXT
Form fields: title. On submit, processes form data on the server.
Visible content: Server Actions — 3 Ways | 1. Separate file | Create
TSX
// app/actions-comparison/TodoFormWithState.tsx
'use client'
import { useActionState } from 'react'
import { createTodoAction } from './actions'

export default function TodoFormWithState() {
  const [state, action, pending] = useActionState(createTodoAction, null)
  return (
    <form action={action}>
      <input name="title" required disabled={pending} />
      <button type="submit">{pending ? 'Creating...' : 'Create'}</button>
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state?.success && <p style={{ color: 'green' }}>Todo created!</p>}
    </form>
  )
}
TS
// app/actions-comparison/actions.ts
'use server'
import { revalidatePath } from 'next/cache'

export async function createTodoAction(prevState: any, formData: FormData) {
  const title = formData.get('title') as string
  if (!title || title.length < 2) {
    return { error: 'Title must be at least 2 characters' }
  }
  await fetch('https://jsonplaceholder.typicode.com/todos', {
    method: 'POST', body: JSON.stringify({ title, completed: false })
  })
  revalidatePath('/actions-comparison')
  return { success: true }
}

export async function createTodoInline(formData: FormData) {
  const title = formData.get('title') as string
  await fetch('https://jsonplaceholder.typicode.com/todos', {
    method: 'POST', body: JSON.stringify({ title, completed: false })
  })
  revalidatePath('/actions-comparison')
}

5. Form "action" and Progressive Enhancement

The most important feature of Server Actions is Progressive Enhancement—even if JavaScript is disabled in the browser, forms can still be submitted successfully.

(1) Native HTML Form Behavior

TSX
// app/progressive/page.tsx — Progressive Enhancement:JS It still works even when disabled
export default function ProgressivePage() {
  return (
    <form action={async (formData: FormData) => {
      'use server'
      const email = formData.get('email') as string
      const message = formData.get('message') as string
      await sendEmail({ to: email, body: message })
      revalidatePath('/progressive')
    }}>
      <label>Email: <input name="email" type="email" required /></label>
      <label>Message: <textarea name="message" required /></label>
      <button type="submit">Send</button>
    </form>
  )
}
Status Native HTML + JavaScript Enhancements
Submission Method POST to the current URL Using fetch + RSC Payload
Refresh Page Refresh Entire Page No Full-Page Refresh (Soft Navigation)
User Experience Traditional Form Submission Flicker-Free Submission
Feature ✅ Fully available ✅ Enhanced experience

(2) Using the action property vs. onSubmit

Method HTML Requirements JS Requirements Progressive Enhancement
<form action={serverAction}> ✅ No JS required ✅ Enhanced ✅ Yes
<form onSubmit={handler}> ❌ Required preventDefault ✅ Required ❌ No

▶ Example: JS Scenario Disabling Test (Difficulty ⭐⭐)

Output:

TEXT
A form with input fields and a submit button.
On submit, the server action processes the data and refreshes the page cache.
TSX
// app/no-js-demo/page.tsx — Testing Progressive Enhancement
export default function NoJsDemoPage() {
  return (
    <form action={async (formData: FormData) => {
      'use server'
      const item = formData.get('item') as string
      await fetch('https://jsonplaceholder.typicode.com/todos', {
        method: 'POST', body: JSON.stringify({ title: item, completed: false })
      })
      revalidatePath('/no-js-demo')
    }}>
      <input name="item" required placeholder="Enter todo item" />
      <button type="submit">Add Todo</button>
    </form>
  )
}

Output:

TEXT
A form with input fields and a submit button.
On submit, the server action processes the data and refreshes the page cache.

Test Method:

  1. Normal usage: Enter text → Click "Submit" → The list is updated
  2. Disable JavaScript: DevTools → Settings → Disable JavaScript → Refresh → Submit the form → It still works

6. Zod Validation Integration

Server Actions should always validate input data. Zod is the most popular validation library in the TypeScript ecosystem.

(1) Basic Verification Mode

TS
// app/actions/schema.ts
import { z } from 'zod'

export const projectSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters').max(100),
  description: z.string().max(500).optional(),
  dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format').optional(),
})
TS
// app/actions/create.ts
'use server'
import { revalidatePath } from 'next/cache'
import { projectSchema } from './schema'

export async function createProject(prevState: any, formData: FormData) {
  const validated = projectSchema.safeParse({
    name: formData.get('name'),
    description: formData.get('description'),
    dueDate: formData.get('dueDate'),
  })

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

  try {
    await db.project.create({ data: validated.data })
    revalidatePath('/projects')
    return { success: true }
  } catch (err) {
    return { error: 'Failed to create project' }
  }
}

(2) The client displays a validation error

TSX
// app/projects/CreateProjectForm.tsx
'use client'
import { useActionState } from 'react'
import { createProject } from '@/app/actions/create'

export function CreateProjectForm() {
  const [state, action, pending] = useActionState(createProject, null)

  return (
    <form action={action}>
      <div>
        <input name="name" required placeholder="Project name" disabled={pending} />
        {state?.errors?.name && <p style={{ color: 'red' }}>{state.errors.name[0]}</p>}
      </div>
      <div>
        <textarea name="description" placeholder="Description" disabled={pending} />
      </div>
      <div>
        <input name="dueDate" type="date" disabled={pending} />
        {state?.errors?.dueDate && <p style={{ color: 'red' }}>{state.errors.dueDate[0]}</p>}
      </div>
      <button type="submit" disabled={pending}>
        {pending ? 'Creating...' : 'Create Project'}
      </button>
      {state?.success && <p style={{ color: 'green' }}>Project created!</p>}
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
    </form>
  )
}

▶ Example: Complete Zod Validation Form (Difficulty: ⭐⭐)

Output:

TEXT
A form with optimistic UI updates using useActionState.
Visible text: } | } | Project created! | }
      {state?.error &&
TSX
// app/zod-demo/page.tsx — Zod Verification + Server Actions
import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const contactSchema = z.object({
  name: z.string().min(2),
  email: z.string().email('Invalid email'),
  message: z.string().min(10, 'Message too short').max(1000),
})

export default function ZodDemoPage() {
  return (
    <form action={async (formData: FormData) => {
      'use server'
      const validated = contactSchema.safeParse({
        name: formData.get('name'),
        email: formData.get('email'),
        message: formData.get('message'),
      })

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

      console.log('Contact form submitted:', validated.data)
      revalidatePath('/zod-demo')
      return { success: true }
    }}>
      <div style={{ marginBottom: 12 }}>
        <label>Name: <input name="name" required /></label>
      </div>
      <div style={{ marginBottom: 12 }}>
        <label>Email: <input name="email" type="email" required /></label>
      </div>
      <div style={{ marginBottom: 12 }}>
        <label>Message: <textarea name="message" required /></label>
      </div>
      <button type="submit">Submit Contact</button>
    </form>
  )
}

Output:

TEXT
Form fields: name, email. On submit, processes data and refreshes the page cache.
Visible content: Name: | Email: | Message:

▶ Example: revalidatePath to refresh the list (Difficulty: ⭐)

Output:

TEXT
The component renders the described UI in the browser.
TSX
// app/todos/page.tsx — Refresh the list after submitting
import { revalidatePath } from 'next/cache'

export default async function TodosPage() {
  const todos = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5', {
    next: { tags: ['todos'] }
  }).then(r => r.json())

  return (
    <div>
      <h1>Todos</h1>
      <ul>{todos.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul>

      <form action={async (formData: FormData) => {
        'use server'
        const title = formData.get('title') as string
        await fetch('https://jsonplaceholder.typicode.com/todos', {
          method: 'POST', body: JSON.stringify({ title, completed: false })
        })
        revalidatePath('/todos')
      }}>
        <input name="title" required />
        <button type="submit">Add</button>
      </form>
    </div>
  )
}

Output:

TEXT
A form with input fields and a submit button.
On submit, the server action processes the data and refreshes the page cache.
Visible text: Todos

▶ Example: Passing Additional Parameters Using bind (Difficulty: ⭐⭐)

Output:

TEXT
Server-side data fetch renders list of items.
Content: Todos

Server Action can be used in conjunction with the <form>'s action property and .bind() method to pass additional parameters.

TSX
// app/bind-demo/page.tsx
import { revalidatePath } from 'next/cache'

export default async function BindDemoPage() {
  const todos = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5', {
    next: { tags: ['bind-todos'] }
  }).then(r => r.json())

  return (
    <div>
      <h1>Todo List with Bind</h1>
      <ul>{todos.map((t: any) => (
        <li key={t.id}>
          {t.title}
          <form action={completeTodo.bind(null, t.id)} style={{ display: 'inline' }}>
            <button type="submit">{t.completed ? '✅' : '⬜'}</button>
          </form>
        </li>
      ))}</ul>
    </div>
  )
}

async function completeTodo(id: number, formData: FormData) {
  'use server'
  await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
    method: 'PATCH', body: JSON.stringify({ completed: true })
  })
  revalidatePath('/bind-demo')
}

7. Complete Example: Task Management System

TSX
// app/tasks/page.tsx — Server Actions Comprehensive Example
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

// ======== Schema ========
const taskSchema = z.object({
  title: z.string().min(1, 'Title required').max(200),
  priority: z.enum(['low', 'medium', 'high']),
  assignee: z.string().min(1, 'Assignee required'),
})

// ======== Task List ========
export default async function TasksPage() {
  const tasks = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=10', {
    next: { tags: ['tasks'] }
  }).then(r => r.json())

  return (
    <div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
      <h1>Task Manager</h1>
      <AddTaskForm />
      <div style={{ marginTop: 24 }}>
        {tasks.map((t: any) => (
          <div key={t.id} style={{ border: '1px solid #ddd', borderRadius: 8, padding: 12, marginBottom: 8 }}>
            <span>{t.title}</span>
            <form action={deleteTask} style={{ display: 'inline', marginLeft: 12 }}>
              <input type="hidden" name="id" value={t.id} />
              <button type="submit" style={{ color: 'red' }}>Delete</button>
            </form>
          </div>
        ))}
      </div>
    </div>
  )
}

// ======== New Task Form ========
function AddTaskForm() {
  return (
    <form action={createTask} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
      <input name="title" required placeholder="Task title" />
      <select name="priority" defaultValue="medium">
        <option value="low">Low</option>
        <option value="medium">Medium</option>
        <option value="high">High</option>
      </select>
      <input name="assignee" required placeholder="Assignee" />
      <button type="submit">Add Task</button>
    </form>
  )
}

// ======== Server Actions ========
async function createTask(formData: FormData) {
  'use server'
  const validated = taskSchema.safeParse({
    title: formData.get('title'),
    priority: formData.get('priority'),
    assignee: formData.get('assignee'),
  })
  if (!validated.success) {
    console.error('Validation failed:', validated.error.flatten())
    return
  }
  await fetch('https://jsonplaceholder.typicode.com/todos', {
    method: 'POST', body: JSON.stringify({ title: validated.data.title, completed: false })
  })
  revalidatePath('/tasks')
}

async function deleteTask(formData: FormData) {
  'use server'
  const id = formData.get('id') as string
  await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, { method: 'DELETE' })
  revalidatePath('/tasks')
}

❓ FAQ

Q What is the difference between a Server Action and a traditional API Route?
A A Server Action is a server-side function that is called directly by the browser (via the RSC Payload protocol); it does not require an HTTP endpoint, automatically handles CSRF, and supports progressive enhancement. API Routes are HTTP endpoints, suitable for third-party integrations, webhooks, and non-browser clients. The two are not interchangeable—Server Actions handle first-party forms, while API Routes handle third-party APIs.
Q Can useActionState only be used in Client Components?
A Yes. useActionState is a client-side hook in React 19 that requires 'use client'. It manages form states (pending, error, success) on the client side. Pure Server Components can use <form action={async} > in inline mode.
Q How do Server Actions prevent CSRF attacks?
A Next.js 16 includes built-in CSRF protection. Server Actions can only be invoked via the RSC Payload protocol (they cannot be simulated using a regular HTTP POST request). The framework automatically verifies that the request origin matches the cookie, so there is no need to manually add a CSRF token.
Q Can an inline Server Action reference props from a component?
A No. An inline 'use server' is a standalone module function marked at compile time—it cannot access variables within a closure. If you need to use a component’s props or state, you must pass them via formData or define them in a separate actions.ts file.
Q Can Server Action parameters be of any type?
A Server Action parameters must be serializable (in accordance with RSC Props rules). FormData, regular objects, arrays, strings, and numbers are supported. Functions, Date, undefined, and Symbol are not supported. If you need complex parameters, pass them via formData or JSON serialization.
Q How does the client retrieve the return value of a Server Action?
A The return value is serialized back to the client via the RSC Payload. In <form action={action}>, the return value is ignored. To retrieve the return value, you must use useActionState(action, initialState) or startTransition + an explicit callAction(). We recommend using useActionState to manage return values.

📖 Summary


📝 Exercises

  1. Basic Exercise (⭐): Create a app/guestbook/page.tsx message board. Use an inline Server Action to process form submissions, output the message content using console.log (to simulate a database write), and refresh the page revalidatePath after submission.

  2. Advanced Exercise (⭐⭐): Implement a task management system with Zod validation in app/todos-zod/page.tsx. Schema requirements: title (1–100 characters), priority (enumeration: low/medium/high), dueDate (optional, YYYY-MM-DD format). Display specific field-level validation errors below the form.

  3. Challenge (⭐⭐⭐): Build a complete "Project Creation" module: app/projects/create/page.tsx includes a multi-field form (name, description, due date, team member list); define CRUD server actions in app/actions/projects.ts; use useActionState to manage submission status (success/error/pending) on the client side; and support image URL input and preview.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏