Next.js 07 ▲ Mutating Data — Server Functions & Server Actions for forms
Next.js App Router separates reading from writing. Reads happen in async Server Components that await data and render HTML. Writes happen through Server Actions: async functions marked with 'use server' that the client invokes via a form submission, a button, or a transition. The action runs on the server, receives the submitted data, mutates the database, and can return both a new UI and a return value in a single HTTP roundtrip. No manual fetch, no JSON endpoint, no useState for the input value.
This chapter covers the mutation path: defining a Server Action with the 'use server' directive, wiring it to a <form> with the action prop, extracting data from FormData, passing additional arguments with bind, validating input with a schema, and returning validation errors to the client with useActionState. It also covers the single-roundtrip response model, the sequential dispatch behavior, and the security boundary that treats every action as a public POST endpoint.
Key point: A Server Action is a public HTTP endpoint. The 'use server' directive hides the implementation from the client bundle, but it does not hide the route. Every action must authenticate the caller, authorize the operation, and validate the input before mutating anything.
Why Server Actions exist
The API route boilerplate problem. Before Server Actions, a form mutation required a Route Handler at /api/submit, a client-side fetch with method: "POST", manual FormData serialization, and a state variable to track loading and errors. Server Actions remove the Route Handler and the fetch. The form’s action prop points directly at an async function, and React handles the serialization and submission .
The cache invalidation problem. After a mutation, the page must show the new data. Without explicit invalidation, Next.js serves the cached HTML from before the mutation. Server Actions integrate with revalidatePath and revalidateTag. When an action calls revalidatePath, Next.js re-renders the current route server-side and includes the fresh RSC payload in the same response as the action result . The user sees the updated UI without a follow-up fetch.
The security boundary problem. A Server Action is compiled into a POST endpoint. The function body stays on the server, but the route is reachable by anyone who can craft the same request. Next.js enforces a CSRF check by comparing the request’s Origin header to the Host, and it caps the request body at 1MB by default . These protections exist because the action is a public API endpoint, not a private function call.
a. Defining a Server Action
A Server Action is an async function with the 'use server' directive. The directive can be placed at the top of the function body for an inline action, or at the top of a separate file to mark every export as a Server Action .
// app/actions.ts
'use server'
import { db } from '@/lib/db'
export async function createPost(formData: FormData) {
const title = formData.get('title')
const content = formData.get('content')
await db.post.create({ data: { title, content } })
}
The separate-file approach is required for actions invoked from Client Components, because Client Components cannot define Server Functions. They can only import them from a file with the module-level directive .
b. Wiring the action to a form
React extends the <form> element so that the action prop accepts a Server Action. When the form is submitted, the function receives a FormData object containing the form fields .
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input type="text" name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</form>
)
}
The name attribute on each input determines the key used in formData.get(). The form submits as a POST request to the current route, and React dispatches the action. Progressive enhancement is built in: the form submits even if JavaScript has not loaded .
c. Passing additional arguments with bind
A form submission only carries the form fields. If the action needs a value that is not an input—such as a userId from the URL—the bind method partially applies that argument before the action is passed to the form .
// app/posts/[id]/comment-form.tsx
import { postComment } from './actions'
export default function CommentForm({ postId }: { postId: string }) {
const postCommentWithId = postComment.bind(null, postId)
return (
<form action={postCommentWithId}>
<textarea name="body" required />
<button type="submit">Post</button>
</form>
)
}
The action receives postId as its first argument and formData as its second . The alternative is a hidden input, but that value becomes part of the rendered HTML and is visible to the user.
d. Validation and returning errors
Client-side validation with required and type="email" catches obvious mistakes. Server-side validation with a schema library like Zod catches everything else, including requests that bypass the form entirely .
// app/actions.ts
'use server'
import { z } from 'zod'
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
})
export async function createUser(prevState: any, formData: FormData) {
const validated = schema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
await db.user.create({ data: validated.data })
}
To display the errors, the form component must be a Client Component that uses useActionState. The hook wraps the action, tracks the returned state, and provides a pending boolean for loading states .
// app/signup.tsx
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
const initialState = { errors: {} }
export function Signup() {
const [state, formAction, pending] = useActionState(createUser, initialState)
return (
<form action={formAction}>
<input name="email" required />
{state.errors?.email && <p>{state.errors.email}</p>}
<input name="name" required />
{state.errors?.name && <p>{state.errors.name}</p>}
<button disabled={pending}>Sign up</button>
</form>
)
}
The action’s return value becomes the next state. When the action returns { errors: ... }, the component renders them inline. When it returns nothing, the state is unchanged .
Complete Example Session
// ============================================
// PART 1: DEFINE THE ACTION FILE
// ============================================
// app/posts/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'
// The module-level directive marks every export as a Server Action.
// ============================================
// PART 2: DEFINE THE VALIDATION SCHEMA
// ============================================
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
})
// Schema is shared between server and client.
// The client can use it for immediate feedback.
// The server uses it as the authoritative check.
// ============================================
// PART 3: DEFINE THE ACTION
// ============================================
export async function createPost(prevState: any, formData: FormData) {
const validated = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
await db.post.create({ data: validated.data })
revalidatePath('/posts')
return { success: true }
}
// ============================================
// PART 4: WIRE THE FORM (SERVER COMPONENT)
// ============================================
// app/posts/new/page.tsx
import { createPost } from '../actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</form>
)
}
// The form submits to the action.
// No client-side JavaScript is required for submission.
// ============================================
// PART 5: ADD CLIENT-SIDE ERROR DISPLAY
// ============================================
// app/posts/new/form.tsx
'use client'
import { useActionState } from 'react'
import { createPost } from '../actions'
const initialState = { errors: {} }
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initialState)
return (
<form action={formAction}>
<input name="title" required />
{state.errors?.title && <p>{state.errors.title}</p>}
<textarea name="content" required />
{state.errors?.content && <p>{state.errors.content}</p>}
<button disabled={pending}>Create</button>
</form>
)
}
// ============================================
// PART 6: PASS ADDITIONAL ARGUMENTS WITH BIND
// ============================================
// app/posts/[id]/comment-form.tsx
import { postComment } from './actions'
export default function CommentForm({ postId }: { postId: string }) {
return (
<form action={postComment.bind(null, postId)}>
<textarea name="body" required />
<button type="submit">Post</button>
</form>
)
}
// postComment receives (postId, formData).
// ============================================
// PART 7: THE ACTION WITH BOUND ARGUMENT
// ============================================
// app/posts/actions.ts
export async function postComment(postId: string, formData: FormData) {
const body = formData.get('body')
await db.comment.create({ data: { postId, body } })
revalidatePath(`/posts/${postId}`)
}
// ============================================
// PART 8: SECURITY CHECK IN THE ACTION
// ============================================
export async function createPost(prevState: any, formData: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// Only authenticated users can create posts.
// The action is a public POST endpoint;
// authentication is not optional.
}
// ============================================
// PART 9: REVALIDATION
// ============================================
import { revalidatePath } from 'next/cache'
// After mutation, invalidate the cached page
revalidatePath('/posts')
// The current route re-renders server-side,
// and the fresh RSC payload is included
// in the action's response.
// ============================================
// PART 10: PENDING STATE WITH useFormStatus
// ============================================
// app/posts/new/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Creating...' : 'Create'}</button>
}
// useFormStatus must be used inside a <form>.
// It reads the pending state of the parent form.
The ten parts covered the action file, the schema, the action definition, form wiring, client-side error display, bind arguments, the bound action, security checks, revalidation, and pending states.
Quick Reference
Server Action directives
| Location | Directive | Use case |
|---|---|---|
| Function body | 'use server' | Inline action in a Server Component |
| File top | 'use server' | Actions shared with Client Components |
Form wiring
| Prop | Accepts | Behavior |
|---|---|---|
action | Server Action | Submits form via action |
formAction | Server Action | Action on a specific button |
Data and state
| API | Purpose |
|---|---|
formData.get(name) | Extract a field value |
Object.fromEntries(formData) | Convert all fields to an object |
bind(null, arg) | Partially apply an argument |
useActionState | Track action return value and pending |
useFormStatus | Pending state inside a form |
Validation and revalidation
| API | Purpose |
|---|---|
schema.safeParse(data) | Validate without throwing |
error.flatten().fieldErrors | Structured field errors |
revalidatePath(path) | Invalidate cached route |
Security limits
| Protection | Default | Configuration |
|---|---|---|
| CSRF origin check | Same origin | serverActions.allowedOrigins |
| Body size limit | 1 MB | serverActions.bodySizeLimit |
Best Practices
✅ Do This:
// Authenticate inside every action
const session = await auth()
if (!session?.user) throw new Error('Unauthorized') // ✅
// Validate with safeParse before mutating
const validated = schema.safeParse(data) // ✅
// Use a separate file with 'use server' for shared actions
// app/actions.ts // ✅
// Call revalidatePath after a successful mutation
revalidatePath('/posts') // ✅
// Use useActionState for error display
const [state, formAction, pending] = useActionState(...) // ✅
❌ Don’t Do This:
// Trust the form fields without validation
const title = formData.get('title') // ❌
// Skip authentication because the form is on a protected page
// The action route is public // ❌
// Define a Server Action inside a Client Component
'use client'
async function action() { 'use server' } // ❌ (not allowed)
// Rely on Promise.all to parallelize Server Actions
await Promise.all([actionA(), actionB()]) // ❌ (sequential dispatch)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Action throws “Unauthorized” | No authentication check in the action | Add auth() check at the top |
| Form fields are empty | Inputs lack name attributes | Add name="fieldName" |
| Validation errors not displayed | Form is a Server Component | Convert to Client Component with useActionState |
| Page does not show new data | revalidatePath not called | Add revalidation after mutation |
bind argument is not received | Wrong argument order | Bound arguments come first, formData last |
| Action body exceeds 1MB | Default body size limit | Configure serverActions.bodySizeLimit |
Real-World Examples
1. Basic action
'use server'
export async function create(formData: FormData) { await db.create(...) }
2. Form wiring
<form action={create}><input name="title" /><button>Save</button></form>
3. Bind an ID
<form action={update.bind(null, postId)}>
4. Validate with Zod
const validated = schema.safeParse(Object.fromEntries(formData))
5. Return field errors
if (!validated.success) return { errors: validated.error.flatten().fieldErrors }
6. Display errors with useActionState
const [state, formAction] = useActionState(action, { errors: {} })
7. Pending button
const { pending } = useFormStatus()
8. Revalidate after mutation
revalidatePath('/posts')
9. Authenticate inside action
const session = await auth()
if (!session) throw new Error('Unauthorized')
10. Convert FormData to object
const data = Object.fromEntries(formData)
Visual
The mutation flow
┌─────────────────────────────────────────────────────────────┐
│ SERVER ACTION FLOW │
│ │
│ Browser Server │
│ │ │ │
│ ├── form submit ──────────►│ │
│ │ (POST) │ │
│ │ ├── authenticate │
│ │ ├── validate │
│ │ ├── mutate DB │
│ │ ├── revalidatePath │
│ │ ├── re-render route │
│ │◄── RSC payload + ────────┤ │
│ │ action result │ │
│ │ │ │
│ └── UI updates │ │
│ │
│ One roundtrip. No follow-up fetch. │
└─────────────────────────────────────────────────────────────┘
useActionState loop
┌─────────────────────────────────────────────────────────────┐
│ useActionState │
│ │
│ Component Action │
│ │ │ │
│ ├── formAction ──────────►│ │
│ │ ├── validate │
│ │ │ │
│ │◄── return value ────────┤ │
│ │ │ │
│ ├── state = return value │ │
│ │ │ │
│ └── render errors │ │
│ │
│ The action's return value becomes the next state. │
└─────────────────────────────────────────────────────────────┘
Security boundary
┌─────────────────────────────────────────────────────────────┐
│ SERVER ACTION AS PUBLIC ENDPOINT │
│ │
│ 'use server' │
│ ├── Hides implementation from client bundle │
│ ├── Generates a POST route │
│ └── Does NOT hide the route │
│ │
│ Protections: │
│ ├── CSRF: Origin vs Host check │
│ ├── Body size: 1MB default │
│ └── Encrypted action IDs │
│ │
│ Your responsibility: │
│ ├── Authentication (who is calling?) │
│ ├── Authorization (can they do this?) │
│ └── Validation (is the data well-formed?) │
└─────────────────────────────────────────────────────────────┘
Sequential dispatch
┌─────────────────────────────────────────────────────────────┐
│ CLIENT DISPATCH ORDER │
│ │
│ User triggers action A │
│ User triggers action B │
│ User triggers action C │
│ │
│ A runs ──► completes │
│ │ │
│ ▼ │
│ B runs ──► completes │
│ │ │
│ ▼ │
│ C runs ──► completes │
│ │
│ Actions are dispatched one at a time per client. │
│ Promise.all does not parallelize them. │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Directive | 'use server' |
| Placement | Function body or file top |
| Form prop | action={serverAction} |
| Data argument | FormData |
| Additional arguments | bind(null, arg) |
| Validation | Zod safeParse |
| Error display | useActionState |
| Pending state | useFormStatus or useActionState |
| Cache invalidation | revalidatePath |
| Default body limit | 1 MB |
| CSRF check | Origin vs Host |
Key takeaways:
- A Server Action is a public POST endpoint. The
'use server'directive removes the implementation from the client bundle, but it does not remove the route. Every action must authenticate, authorize, and validate before mutating . - The form’s
actionprop replaces the API route and the client-side fetch. The action receivesFormDatadirectly, and React handles the submission. Progressive enhancement works by default in Server Components . bindpasses values that are not form fields. ApostIdfrom the URL, auserIdfrom the session, or any other server-known value can be partially applied so the action receives it before theFormData.- Validation belongs on the server, even if it also exists on the client. The client-side
requiredattribute is a convenience. The Zod schema in the action is the authoritative check, because the action can be called without the form . useActionStateconnects the action’s return value to the UI. The form component must be a Client Component to use the hook. The action’s return value becomes the next state, and thependingboolean drives loading indicators .revalidatePathis what makes the new data visible. Without it, Next.js serves the cached HTML from before the mutation. With it, the current route re-renders server-side and the fresh payload arrives in the action’s response .
Remember: Server Actions remove the boilerplate of API routes and client-side fetch, but they do not remove the security obligations. The action is reachable by anyone who can send a POST request. Authenticate the caller, authorize the operation, validate the input, and revalidate the cache after the mutation. Use useActionState to return validation errors to the client and useFormStatus to show a pending state. The single-roundtrip response means the user sees the updated UI without a follow-up request, but only if the action calls revalidatePath or one of its equivalents.
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!