Next.js7 min read
Next.js Server Actions Guide: Forms, Validation, Security
Next.js Server Actions guide for Next.js 16: build forms with useActionState, validate with Zod, revalidate with updateTag and secure every action.
Next.js Server Actions are async functions marked with 'use server' that run on the server but can be called directly from a form or event handler. You pass one to <form action={...}>, read the FormData, write to your database, then call updateTag or revalidatePath so the page shows fresh data, all in a single round trip. Each action is also a public POST endpoint, so it needs the same authentication, authorization and validation as any API route.
Below is the full pattern I use: a validated form with pending and error states, cache revalidation, optimistic UI, and the security checklist.
Key takeaways
- Define actions in a
'use server'file and import them into components. Inline actions work but are harder to audit. - Use
useActionState(fromreact) for returned errors and pending state, anduseFormStatus(fromreact-dom) for submit buttons. - Validate
FormDatawith Zod on the server. Client validation is only a UX convenience. - After a mutation, call
updateTagfor read-your-own-writes,revalidatePathfor a route, orrefreshfor uncached data. Call these beforeredirect. - Authenticate and authorize inside every action. A form that only renders for admins does not protect the endpoint.
- Actions are dispatched one at a time per client, so do not use them for parallel reads.
How Next.js Server Actions work
When the compiler sees 'use server', it replaces the function in the client bundle with a reference: an encrypted action ID plus a dispatcher that POSTs to the current page. The real code never ships to the browser.
When an action calls updateTag, revalidatePath, refresh, mutates cookies or calls redirect, Next.js re-renders the current route on the server and returns the action result plus the new RSC payload in the same response. You do not need a follow-up fetch.
Two behaviors to keep in mind:
- Sequential dispatch. If a user triggers three actions quickly, the client sends them one after another.
Promise.allover actions does not run them in parallel. revalidateTagwith a profile does not re-render immediately. It is stale-while-revalidate by design, so the change shows on a later read.
Building a form with Server Actions and useActionState
Start with the schema and state type, shared by the action and the form:
// app/projects/definitions.ts
import * as z from 'zod'
export const ProjectSchema = z.object({
name: z.string().trim().min(3, { error: 'Name must be at least 3 characters.' }),
budget: z.coerce.number().int().positive({ error: 'Budget must be a positive number.' }),
})
export type ProjectFormState = {
errors?: { name?: string[]; budget?: string[] }
message?: string
values?: { name?: string; budget?: string }
}Now the action. It takes the previous state as its first argument because it will be used with useActionState:
// app/projects/actions.ts
'use server'
import * as z from 'zod'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { verifySession } from '@/lib/dal'
import { ProjectSchema, type ProjectFormState } from './definitions'
export async function createProject(
_prev: ProjectFormState,
formData: FormData,
): Promise<ProjectFormState> {
const session = await verifySession() // redirects to /login if missing
const raw = {
name: String(formData.get('name') ?? ''),
budget: String(formData.get('budget') ?? ''),
}
const parsed = ProjectSchema.safeParse(raw)
if (!parsed.success) {
return {
errors: z.flattenError(parsed.error).fieldErrors,
values: raw,
}
}
let projectId: string
try {
const project = await db.project.create({
data: { ...parsed.data, ownerId: session.userId },
})
projectId = project.id
} catch {
return { message: 'Could not save the project. Please try again.', values: raw }
}
updateTag(`projects:${session.userId}`)
redirect(`/projects/${projectId}`)
}Notice redirect sits outside the try. It works by throwing a special error, so a catch around it would swallow the navigation. Also notice that ownerId comes from the session, never from the form.
The form is a Client Component:
// app/projects/new/project-form.tsx
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { createProject } from '../actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Create project'}
</button>
)
}
export function ProjectForm() {
const [state, formAction] = useActionState(createProject, {})
return (
<form action={formAction} noValidate>
<label htmlFor="name">Name</label>
<input id="name" name="name" defaultValue={state.values?.name} />
{state.errors?.name && <p role="alert">{state.errors.name[0]}</p>}
<label htmlFor="budget">Budget</label>
<input id="budget" name="budget" inputMode="numeric" defaultValue={state.values?.budget} />
{state.errors?.budget && <p role="alert">{state.errors.budget[0]}</p>}
{state.message && <p role="alert">{state.message}</p>}
<SubmitButton />
</form>
)
}Returning values matters. React resets uncontrolled form fields after an action completes, so without it the user loses what they typed when validation fails.
The form also works before JavaScript loads: a native <form action> posts normally and the page re-renders with the result. For a deeper look at the hooks, see React 19 hooks: useActionState, useOptimistic and use. If you prefer client-side validation with React Hook Form on top, the React Hook Form and Zod guide shows how to share one schema between both.
Passing extra arguments with bind
Need an ID that is not a form field? Use bind rather than a hidden input:
// app/projects/[id]/archive-button.tsx
import { archiveProject } from '../actions'
export function ArchiveButton({ projectId }: { projectId: string }) {
const archive = archiveProject.bind(null, projectId)
return (
<form action={archive}>
<button type="submit">Archive</button>
</form>
)
}// in app/projects/actions.ts
export async function archiveProject(projectId: string) {
const session = await verifySession()
const result = await db.project.updateMany({
where: { id: projectId, ownerId: session.userId }, // ownership check
data: { archived: true },
})
if (result.count === 0) throw new Error('Not found')
updateTag(`projects:${session.userId}`)
}Bound arguments are not encrypted. Treat projectId as untrusted input, exactly like a form field, which is why the query filters by ownerId too.
Revalidation after Server Actions
Pick the narrowest tool that matches what changed:
| Function | Effect | When to use it |
|---|---|---|
updateTag(tag) |
Expires tagged cache now, re-renders the current route | User should see their own change immediately |
revalidatePath(path) |
Invalidates everything for a path | One route changed and nothing is tagged |
revalidateTag(tag, 'max') |
Stale-while-revalidate, no immediate re-render | Shared content where a short delay is fine |
refresh() |
Re-renders uncached data, cache untouched | Counters, notification badges |
redirect(url) |
Navigates and streams the destination | After create flows. Call it last |
updateTag and refresh are Next.js 16 APIs and only work inside Server Actions. The full caching model is in Next.js caching explained.
Optimistic updates with useOptimistic
For actions that almost always succeed (likes, toggles, reordering), show the result before the server confirms:
'use client'
import { useOptimistic, startTransition } from 'react'
import { toggleStar } from './actions'
export function StarButton({ starred, repoId }: { starred: boolean; repoId: string }) {
const [optimisticStarred, setOptimisticStarred] = useOptimistic(starred)
return (
<button
onClick={() =>
startTransition(async () => {
setOptimisticStarred(!optimisticStarred)
await toggleStar(repoId)
})
}
>
{optimisticStarred ? 'Starred' : 'Star'}
</button>
)
}If the action throws, React drops the optimistic value and the UI falls back to the server state.
Server Actions security checklist
Next.js gives you framework-level protection:
- CSRF check: the request
Originmust match theHost(orX-Forwarded-Host). Server Actions only accept POST. - Encrypted action IDs and removal of unused actions from the client bundle.
- Closure encryption: variables captured by inline actions are encrypted before reaching the client.
- Body size limit: 1MB by default.
None of that checks who the user is. Inside every action:
- Authenticate. Read the session on the server. Never accept a user ID from the client.
- Authorize. Check that this user may touch this record, ideally in the database query itself.
- Validate. Parse every input with a schema. Shape validation does not prove ownership, so you need both.
- Return the minimum. Return values are serialized to the client. Do not return raw database rows.
- Rate limit expensive or abuse-prone actions like signup, login and email sending.
If a reverse proxy or CDN rewrites the host, or you need larger uploads, configure it:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: ['admin.example.com'],
bodySizeLimit: '4mb',
},
},
}
export default nextConfigWhen self-hosting on multiple instances, set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to the same value everywhere so action references can be decrypted on any instance. Authentication patterns for actions are covered in the Next.js authentication guide.
Common Server Actions mistakes
- Calling
redirectinsidetry/catch. The catch swallows it. Redirect after the block. - Revalidating after
redirect. Code afterredirectnever runs. - Trusting hidden inputs or bound args. Both are user-controlled.
- Using actions for reads. They are POST and sequential. Fetch in Server Components.
- Deploying without a retry path. Action IDs change between deployments, so a user on an old tab can hit "Failed to find Server Action". Show a "please refresh" message instead of a blank error.
- Forgetting
useFormStatusmust live inside the form. It reads the status of the parent<form>, so it returns nothing useful if called in the component that renders the form.
FAQ
Are Next.js Server Actions secure?
They have built-in protections such as an Origin check against CSRF, encrypted action IDs and a 1MB default body limit, but every action is still a public POST endpoint. You must authenticate, authorize and validate input inside each action.
Should I use Server Actions or Route Handlers?
Use Server Actions for mutations triggered by your own UI, such as forms and buttons. Use Route Handlers for webhooks, public APIs, file streaming and anything called by third parties or non-React clients.
Can Server Actions fetch data?
They can, but they are a poor fit. The client dispatches actions one at a time and they use POST, so reads should happen in Server Components or Route Handlers instead.
How do I show validation errors from a Server Action?
Use React's useActionState hook. The action receives the previous state and FormData, returns an object with field errors, and the component renders those errors from the returned state.
Do I need revalidatePath after a Server Action?
Only if the action changes data that is cached or shown on the current page. Use updateTag for read-your-own-writes, revalidatePath when one route is affected, or refresh when only uncached data changed.
Wrapping up
Server Actions remove most of the glue code between a form and your database, but they do not remove the need to think like an API designer. Validate on the server, derive identity from the session, pick the right revalidation call, and redirect last. The official Server Actions guide covers deployment and configuration details if you self-host.
- #nextjs
- #server-actions
- #forms
- #security
- #react
Keep reading
Next.js8 min read
Next.js App Router vs Pages Router: Migration Guide
Next.js App Router vs Pages Router explained: the real differences, when to migrate, and a step-by-step migration guide updated for Next.js 16.4.
Read articleNext.js8 min read
Next.js Authentication Guide: Sessions, proxy.ts, DAL
Next.js authentication guide for Next.js 16: pick an auth library, manage sessions, protect routes with proxy.ts and secure Server Components and Actions.
Read articleNext.js10 min read
Deploy Next.js: Vercel vs Self-Hosting with Docker
Deploy Next.js on Vercel or self-host with Docker and standalone output. Compare costs, features, caching, ISR and the gotchas before you choose a host.
Read article