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
- How
'use server'Commands and Server Actions Are Defined <form action={}>'s form processing mode- Progressive Enhancement: Forms can be submitted even without JavaScript
revalidatePath()Refresh the page data after submission- Zod integration for form parameter validation
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:
- Write a
POST /api/projectsAPI route (20 lines) - Write a
fetch()client call (10 lines) - Handle the CSRF token (10 lines)
- Handle loading, error, and success states (20 lines)
- Refresh page data (10 rows)
- Input Validation (10 lines)
- Total: ~80 lines of code
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.
// 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
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) |
(1) Separate File Mode (Recommended)
// 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')
}
// 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)
// 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>
)
}
(3) useActionState Pattern (Recommended for Stateful Forms)
// 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:
A form with fields: todo.
On submit, the server action processes the data and invalidates the relevant cache tags.
// 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:
Form fields: title. On submit, processes form data on the server.
Visible content: Server Actions — 3 Ways | 1. Separate file | Create
// 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>
)
}
// 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
// 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:
A form with input fields and a submit button.
On submit, the server action processes the data and refreshes the page cache.
// 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:
A form with input fields and a submit button.
On submit, the server action processes the data and refreshes the page cache.
Test Method:
- Normal usage: Enter text → Click "Submit" → The list is updated
- 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
// 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(),
})
// 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
// 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:
A form with optimistic UI updates using useActionState.
Visible text: } | } | Project created! | }
{state?.error &&
// 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:
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:
The component renders the described UI in the browser.
// 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:
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:
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.
// 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
// 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
useActionState only be used in Client Components?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.'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.formData or JSON serialization.<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
- Server Action is defined using the
'use server'directive and supports three methods: standalone files, inline, and useActionState. <form action={action}>is the most recommended method for form submission and natively supports progressive enhancement.- Progressive Enhancement allows forms to still be submitted via native POST even when JavaScript is disabled
revalidatePath()andrevalidateTag()are used in Server Action to refresh cached data after submission- Zod integration implements type-safe validation of Server Action parameters
- Server Actions includes built-in CSRF protection; there is no need to manually add a token
- Use
useActionState(action, initialState)to manage pending items, return values, and error statuses
📝 Exercises
-
Basic Exercise (⭐): Create a
app/guestbook/page.tsxmessage board. Use an inline Server Action to process form submissions, output the message content usingconsole.log(to simulate a database write), and refresh the pagerevalidatePathafter submission. -
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. -
Challenge (⭐⭐⭐): Build a complete "Project Creation" module:
app/projects/create/page.tsxincludes a multi-field form (name, description, due date, team member list); define CRUD server actions inapp/actions/projects.ts; useuseActionStateto manage submission status (success/error/pending) on the client side; and support image URL input and preview.



