Skip to content

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 (from react) for returned errors and pending state, and useFormStatus (from react-dom) for submit buttons.
  • Validate FormData with Zod on the server. Client validation is only a UX convenience.
  • After a mutation, call updateTag for read-your-own-writes, revalidatePath for a route, or refresh for uncached data. Call these before redirect.
  • 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.all over actions does not run them in parallel.
  • revalidateTag with 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:

TypeScript
// 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:

TypeScript
// 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:

TSX
// 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:

TSX
// 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>
  )
}
TypeScript
// 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:

TSX
'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 Origin must match the Host (or X-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:

  1. Authenticate. Read the session on the server. Never accept a user ID from the client.
  2. Authorize. Check that this user may touch this record, ideally in the database query itself.
  3. Validate. Parse every input with a schema. Shape validation does not prove ownership, so you need both.
  4. Return the minimum. Return values are serialized to the client. Do not return raw database rows.
  5. 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:

TypeScript
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    serverActions: {
      allowedOrigins: ['admin.example.com'],
      bodySizeLimit: '4mb',
    },
  },
}

export default nextConfig

When 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 redirect inside try/catch. The catch swallows it. Redirect after the block.
  • Revalidating after redirect. Code after redirect never 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 useFormStatus must 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

Next.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