Next.js Server Actions: Forms and Mutations Without API Routes
Server Actions (Server Functions) let a form or button call a function that runs on the server, without writing an API route. How 'use server' works, forms with useActionState, validation with Zod, revalidating data, pending states — and why every action is a public endpoint that needs its own auth checks.
Server Actions — called Server Functions in newer React and Next.js docs — let your UI call a function that runs on the server, as if it were a local function. No API route, no fetch, no JSON handling. (React Server Components explained)
// app/todos/page.tsx
import { revalidatePath } from 'next/cache'
export default function Page() {
async function addTodo(formData: FormData) {
'use server'
const title = String(formData.get('title'))
await db.todo.create({ data: { title } })
revalidatePath('/todos')
}
return (
<form action={addTodo}>
<input name="title" />
<button>Add</button>
</form>
)
}
Submitting the form sends a POST to the server, runs addTodo, and refreshes the page data. It works even before JavaScript loads.
How it works
The 'use server' directive marks a function (or a whole file) as server-only. Next.js gives the browser a reference to it; calling it sends a POST request with the arguments, runs the function on the server, and returns the result.
Put shared actions in their own file:
// app/actions.ts
'use server'
export async function deleteTodo(id: string) { /* ... */ }
and import them into client components, where you can call them from event handlers:
'use client'
import { deleteTodo } from './actions'
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
Validation, errors and pending state
Use useActionState to get the action's result and a pending flag:
'use client'
import { useActionState } from 'react'
import { createInvoice } from './actions'
export function InvoiceForm() {
const [state, formAction, pending] = useActionState(createInvoice, { error: null })
return (
<form action={formAction}>
<input name="amount" />
{state.error && <p className="error">{state.error}</p>}
<button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>
</form>
)
}
// actions.ts
'use server'
import { z } from 'zod'
const Invoice = z.object({ amount: z.coerce.number().positive() })
export async function createInvoice(prev: unknown, formData: FormData) {
const parsed = Invoice.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { error: 'Amount must be a positive number' }
// ...save
return { error: null }
}
(Validating input with Zod, React Hook Form)
After a change, call revalidatePath or revalidateTag so pages show fresh data, or redirect() to navigate.
The security part: every action is a public endpoint
This is where AI-generated code most often goes wrong. A Server Action looks like an internal function, but it's an HTTP endpoint anyone can call with any arguments — not just your form, and not just with the values your UI allows.
Inside every action:
- Authenticate — who is calling?
- Authorise — may this user do this to this resource?
- Validate all inputs — types, ranges, lengths.
'use server'
export async function deleteTodo(id: string) {
const user = await getCurrentUser()
if (!user) throw new Error('Not logged in')
const result = await db.todo.deleteMany({ where: { id, ownerId: user.id } }) // ownership in the query
if (result.count === 0) throw new Error('Not found')
}
Without the ownerId check, any logged-in user could delete anyone's todo by calling the action with a different ID. (IDOR explained)
Also:
- Don't rely on proxy/middleware to protect actions — they're POSTs to whatever page uses them, and matchers can miss them. (Next.js proxy)
- Don't return secrets or whole database rows — return only what the UI needs.
- Rate-limit expensive actions (sending email, calling AI). (Implementing rate limiting)
- Next.js checks the request's
Originagainst the host to prevent CSRF; keepserverActions.allowedOriginstight if you configure it. (CSRF explained)
Server Actions vs API routes
| Server Actions | Route handlers (API routes) | |
|---|---|---|
| Best for | Mutations from your own UI | Public APIs, webhooks, mobile apps, third parties |
| Calling | Like a function | HTTP + JSON |
| Works without JS | Yes (forms) | No |
| Called by other apps | Not designed for it | Yes |
Use actions for your app's own forms and buttons; use route handlers for anything external, including webhooks. (Handling webhooks reliably)
Limits
- Request bodies default to a 1 MB limit (
serverActions.bodySizeLimitraises it). For files, upload directly to storage. (413 Request Entity Too Large) - Actions run one at a time per client by default — not for heavy parallel data fetching; fetch data in server components instead.
- Long work belongs in a background job. (Background jobs)
EasySpawn runs your Next.js app as a long-lived Node.js server next to its Postgres database, and Claude Code can audit every Server Action for missing auth checks. See how it works or join the waitlist.
Related: React Server Components Explained · Next.js Middleware Is Now Proxy · What Is IDOR? · Validating Input With Zod
Keep reading
TanStack Query vs useEffect for Data Fetching in React
Fetching in useEffect looks simple until you need loading states, errors, caching, race conditions, refetching and mutations. What TanStack Query handles for you, side-by-side code, mutations with invalidation, and when server components or plain useEffect are still the right choice.
Refresh Tokens Explained: Short-Lived Access, Long-Lived Sessions
Access tokens should expire quickly; refresh tokens let users stay logged in without re-entering passwords. How the pair works, where to store each, refresh token rotation and reuse detection, revocation, handling refresh in the front end, and when plain session cookies are simpler.