A team ships a "rename project" form with a Server Action. The page that renders the form checks that you are logged in and own the project, so the action itself just reads projectId and title from the form and runs an update. A week later someone notices project names changing to garbage across unrelated accounts. Nobody hacked the page. They called the action directly, with a different projectId, because a Server Action is an HTTP endpoint and the page's checks never ran.
Server Actions make mutations feel like calling a function, and that is precisely the danger. Everything you would do in a controller (authenticate, validate, authorize, handle errors without leaking internals) still has to happen, but now it has to happen inside the function, every time. This tip builds that pattern with zod and React 19's useActionState.
What a Server Action Really Is
When you mark a function with 'use server' and pass it to a form's action, Next.js gives it an ID and exposes it as a POST endpoint. The client sends the action ID and the serialized arguments; the server looks up the function and runs it. Some consequences follow directly:
- The action can be invoked by anyone who can send a POST to your app, with any arguments. The UI that normally calls it is not a security boundary.
FormDatavalues are strings (or files) controlled by the client. Hidden inputs are just inputs.- Checks done in the page or layout that rendered the form do not run when the action is called.
Here is the action from the intro. It looks harmless and is completely open:
// app/projects/[id]/actions.ts (do NOT ship this)
'use server'
import { db } from '@/lib/db'
export async function renameProject(formData: FormData) {
const id = formData.get('projectId') as string
const title = formData.get('title') as string
// No session check, no ownership check, no validation:
// anyone who can POST to this page can rename ANY project to ANY string.
await db.project.update({ where: { id }, data: { title } })
}
Next.js does add some protection: actions only accept POST, the framework compares the Origin header with the host to block cross-site calls, and action IDs are not guessable from outside the client bundle. None of that knows who the user is or what they may change.
The Four Steps Every Action Needs
Treat each action like a public controller method. In order:
Authentication comes first so anonymous traffic never reaches your validation or database code. Validation comes before authorization so the ownership query receives clean, typed input. Authorization is per resource: "is logged in" is not the same as "may edit project 7".
Validating Input With zod
Put the schema in a shared module so the server action and, if you want, client-side hints use the same rules. Note the trim() before min(): a title of five spaces should fail.
// lib/validation/project.ts
import { z } from 'zod'
export const renameProjectSchema = z.object({
projectId: z.string().regex(/^\d+$/, 'Invalid project'),
title: z
.string()
.trim()
.min(3, 'Title must be at least 3 characters')
.max(80, 'Title must be at most 80 characters'),
})
export type RenameProjectInput = z.infer<typeof renameProjectSchema>
export type ActionState = {
status: 'idle' | 'success' | 'error'
message: string
fieldErrors: Partial<Record<keyof RenameProjectInput, string[]>>
values?: { title: string }
}
export const initialState: ActionState = { status: 'idle', message: '', fieldErrors: {} }
ActionState is the contract between the action and the form. It is plain serializable data: a status, a human message, per-field errors, and the values to put back in the form when validation fails.
The Secure Action
// app/projects/[id]/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { getCurrentUser } from '@/lib/auth'
import { db } from '@/lib/db'
import { logger } from '@/lib/logger'
import { type ActionState, renameProjectSchema } from '@/lib/validation/project'
export async function renameProject(_prev: ActionState, formData: FormData): Promise<ActionState> {
// 1. Authenticate: this function is a public POST endpoint.
const user = await getCurrentUser()
if (!user) redirect('/login')
// 2. Validate: FormData is untrusted input, whatever the form looks like.
const parsed = renameProjectSchema.safeParse({
projectId: formData.get('projectId'),
title: formData.get('title'),
})
if (!parsed.success) {
return {
status: 'error',
message: 'Please fix the highlighted fields.',
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values: { title: String(formData.get('title') ?? '') },
}
}
const { projectId, title } = parsed.data
// 3. Authorize: the user must own THIS project. Same answer for "missing" and "not yours".
const project = await db.project.findFirst({ where: { id: projectId, ownerId: user.id } })
if (!project) {
return { status: 'error', message: 'Project not found.', fieldErrors: {} }
}
// 4. Mutate, and translate failures into safe messages.
try {
await db.project.update({ where: { id: project.id }, data: { title } })
} catch (error) {
logger.error({ err: error, projectId, userId: user.id }, 'renameProject failed')
return { status: 'error', message: 'Could not save the project. Please try again.', fieldErrors: {} }
}
revalidatePath(`/projects/${project.id}`)
return { status: 'success', message: 'Project renamed.', fieldErrors: {} }
}
A few details matter here:
redirect()sits outside anytry/catch. It works by throwing a special error that Next.js handles. Wrapping it intry/catchswallows the redirect. The same applies tonotFound().- Ownership is part of the query.
findFirst({ where: { id, ownerId } })returns nothing for both "does not exist" and "belongs to someone else", so the response does not reveal which project IDs exist. - Errors are logged with context and replaced by a generic message. Database errors can contain hostnames, SQL and constraint names. In production, Next.js already hides thrown error messages from the client, but returning a typed error state is better UX than letting the action throw into an error boundary.
- Expected errors are returned, unexpected ones are thrown. Validation failures and "not found" are part of the normal flow and come back as state. A bug in your code should still throw and hit
error.tsxand your error tracker.
The Form With useActionState
React 19's useActionState wires the action to local state. It returns the latest state, a wrapped action to pass to the form, and a pending flag:
// app/projects/[id]/rename-form.tsx
'use client'
import { useActionState } from 'react'
import { initialState } from '@/lib/validation/project'
import { renameProject } from './actions'
export function RenameForm({ projectId, title }: { projectId: string; title: string }) {
const [state, formAction, isPending] = useActionState(renameProject, initialState)
return (
<form action={formAction} noValidate>
<input type="hidden" name="projectId" value={projectId} />
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
defaultValue={state.values?.title ?? title}
aria-invalid={Boolean(state.fieldErrors.title)}
aria-describedby="title-error"
/>
<p id="title-error" role="alert">{state.fieldErrors.title?.[0]}</p>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save'}
</button>
{state.message && (
<p aria-live="polite" data-status={state.status}>{state.message}</p>
)}
</form>
)
}
The action receives the previous state as its first argument and the FormData as the second, which is why the signature is (_prev, formData). React resets uncontrolled form fields after an action completes, so on validation errors the action sends back values.title and the input uses it as defaultValue; the user does not lose what they typed. The hidden projectId is convenient for the UI and still untrusted on the server.
If you have many actions, the authentication and validation steps become boilerplate. A small wrapper keeps them impossible to forget, while leaving authorization inside each handler, where the resource is known:
// lib/safe-action.ts
import 'server-only'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { getCurrentUser, type User } from '@/lib/auth'
export type Result<TFields extends string> = {
status: 'idle' | 'success' | 'error'
message: string
fieldErrors: Partial<Record<TFields, string[]>>
}
export function authedAction<S extends z.ZodObject>(
schema: S,
handler: (input: z.infer<S>, user: User) => Promise<Result<Extract<keyof z.infer<S>, string>>>,
) {
return async (_prev: unknown, formData: FormData) => {
const user = await getCurrentUser()
if (!user) redirect('/login')
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return {
status: 'error' as const,
message: 'Please fix the highlighted fields.',
fieldErrors: z.flattenError(parsed.error).fieldErrors,
}
}
return handler(parsed.data, user)
}
}
Testing Server Actions
A Server Action is an async function, so you can test it like one. Mock the framework and data modules with Vitest and assert on the returned state and on what was not called:
// app/projects/[id]/actions.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () => ({
redirect: vi.fn(() => {
throw new Error('NEXT_REDIRECT')
}),
}))
vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn() }))
vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn() } }))
vi.mock('@/lib/db', () => ({ db: { project: { findFirst: vi.fn(), update: vi.fn() } } }))
import { getCurrentUser } from '@/lib/auth'
import { db } from '@/lib/db'
import { initialState } from '@/lib/validation/project'
import { renameProject } from './actions'
const form = (fields: Record<string, string>) => {
const fd = new FormData()
for (const [k, v] of Object.entries(fields)) fd.set(k, v)
return fd
}
describe('renameProject', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getCurrentUser).mockResolvedValue({ id: 'u1' } as never)
})
it('redirects anonymous users before touching the database', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null)
await expect(renameProject(initialState, form({ projectId: '1', title: 'New' }))).rejects.toThrow('NEXT_REDIRECT')
expect(db.project.findFirst).not.toHaveBeenCalled()
})
it('returns field errors for invalid input', async () => {
const state = await renameProject(initialState, form({ projectId: '1', title: ' x ' }))
expect(state.status).toBe('error')
expect(state.fieldErrors.title?.[0]).toMatch(/at least 3/)
expect(db.project.update).not.toHaveBeenCalled()
})
it("refuses to rename someone else's project", async () => {
vi.mocked(db.project.findFirst).mockResolvedValue(null)
const state = await renameProject(initialState, form({ projectId: '7', title: 'Hijacked' }))
expect(state).toMatchObject({ status: 'error', message: 'Project not found.' })
expect(db.project.findFirst).toHaveBeenCalledWith({ where: { id: '7', ownerId: 'u1' } })
expect(db.project.update).not.toHaveBeenCalled()
})
it('hides database errors from the client', async () => {
vi.mocked(db.project.findFirst).mockResolvedValue({ id: '7', ownerId: 'u1' } as never)
vi.mocked(db.project.update).mockRejectedValue(new Error('connection refused at 10.0.3.7'))
const state = await renameProject(initialState, form({ projectId: '7', title: 'Roadmap' }))
expect(state.message).not.toContain('10.0.3.7')
})
})
The two most valuable tests are the negative ones: an anonymous call never touches the database, and a user cannot rename a project they do not own. Those are the behaviours the UI cannot protect, and they regress silently when someone refactors.
Common Pitfalls
- Relying on the page to authenticate. Middleware, layouts and pages do not wrap action calls. Check the session in every action, or in a shared wrapper.
- Trusting IDs from the form. Anything in
FormData, including hidden fields and bound arguments, comes from the client. Re-check ownership on the server. - Casting instead of parsing.
formData.get('title') as stringis a type assertion, not validation. It can benull, aFile, or 10 MB of text. - Catching redirects. A
try/catcharoundredirect()turns a redirect into an error state. Call it after thetry/catch. - Returning raw error messages.
return { message: String(error) }sends database internals to the browser. Log the error, return a generic message. - Exposing internal actions. Every exported function in a
'use server'file is callable. Keep helpers that must not be called directly in a separateserver-onlymodule.
When to Use Server Actions, and When Not To
Use Server Actions for mutations that belong to your own UI: forms, buttons, inline edits. You get progressive enhancement (forms work before JavaScript loads), typed arguments, and built-in revalidation in one place.
Prefer a Route Handler when the endpoint is consumed by something other than your React UI: mobile apps, webhooks, third-party integrations, or anything that needs a stable URL, custom HTTP methods, specific status codes or caching headers. Also avoid Server Actions for reading data; they are designed for mutations, and calls from the same client are processed one at a time. Fetch data in Server Components instead.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Implement the core of a secure Server Action as a plain async function (JavaScript) / function (Python): updateProjectTitle(prevState, form, deps) / update_project_title(prev_state, form, deps). form is a plain object/dict with projectId / project_id and title. deps provides currentUser() / current_user() returning a user like { id } or null/None, findProject(id) / find_project(id) returning a project like { id, ownerId } / {"id", "owner_id"} or null/None, and saveTitle(id, title) / save_title(id, title) which may throw. JavaScript functions in deps are async; Python ones are plain. Return a state object/dict with exactly the keys status, message and fieldErrors (Python: field_errors). Steps, in order: (1) no user -> status 'error', message 'Not signed in', empty field errors; nothing else is called. (2) Validate: the project id must be a string of digits only, otherwise field error key projectId / project_id = 'Invalid project'; the trimmed title must be 3 to 80 characters, otherwise field error key title = 'Title must be 3-80 characters'. Missing or non-string values are invalid. If any field is invalid, return status 'error', message 'Fix the highlighted fields' with all field errors, without looking up the project. (3) If the project is missing or its owner is not the current user, return message 'Project not found'. (4) Call saveTitle with the project id and the TRIMMED title; if it throws, return message 'Could not save, try again' and never expose the original error. (5) On success return status 'success', message 'Saved', empty field errors.
Starter code
async function updateProjectTitle(prevState, form, deps) {
// TODO 1: no user from deps.currentUser() -> { status: 'error', message: 'Not signed in', fieldErrors: {} }
// TODO 2: validate projectId (digits only) and the trimmed title (3-80 chars);
// on failure -> message 'Fix the highlighted fields' with fieldErrors.projectId / fieldErrors.title
// TODO 3: missing project or ownerId !== user.id -> message 'Project not found'
// TODO 4: saveTitle throws -> message 'Could not save, try again' (never the raw error)
// TODO 5: success -> { status: 'success', message: 'Saved', fieldErrors: {} }
throw new Error('TODO')
}
import re
from typing import Any
def update_project_title(prev_state: dict, form: dict, deps: Any) -> dict:
# TODO 1: deps.current_user() is None -> {"status": "error", "message": "Not signed in", "field_errors": {}}
# TODO 2: validate project_id (digits only) and the stripped title (3-80 chars);
# on failure -> "Fix the highlighted fields" with field_errors["project_id"] / ["title"]
# TODO 3: missing project or owner_id != user["id"] -> "Project not found"
# TODO 4: save_title raises -> "Could not save, try again" (never the raw error)
# TODO 5: success -> {"status": "success", "message": "Saved", "field_errors": {}}
raise NotImplementedError
Your solution
Hints
All hints revealedWrite a small fail(message, fieldErrors) helper so every error state has the same shape
Collect all field errors into one object before returning, so the user sees every problem at once
Treat a project owned by someone else exactly like a missing project, so IDs do not leak
async function updateProjectTitle(prevState, form, deps) {
const fail = (message, fieldErrors = {}) => ({ status: 'error', message, fieldErrors })
const user = await deps.currentUser()
if (!user) return fail('Not signed in')
const projectId = typeof form.projectId === 'string' ? form.projectId : ''
const title = typeof form.title === 'string' ? form.title.trim() : ''
const fieldErrors = {}
if (!/^\d+$/.test(projectId)) fieldErrors.projectId = 'Invalid project'
if (title.length < 3 || title.length > 80) fieldErrors.title = 'Title must be 3-80 characters'
if (Object.keys(fieldErrors).length > 0) return fail('Fix the highlighted fields', fieldErrors)
const project = await deps.findProject(projectId)
if (!project || project.ownerId !== user.id) return fail('Project not found')
try {
await deps.saveTitle(projectId, title)
} catch {
return fail('Could not save, try again')
}
return { status: 'success', message: 'Saved', fieldErrors: {} }
}
import re
from typing import Any
def update_project_title(prev_state: dict, form: dict, deps: Any) -> dict:
def fail(message: str, field_errors: dict | None = None) -> dict:
return {"status": "error", "message": message, "field_errors": field_errors or {}}
user = deps.current_user()
if user is None:
return fail("Not signed in")
project_id = form.get("project_id")
project_id = project_id if isinstance(project_id, str) else ""
title = form.get("title")
title = title.strip() if isinstance(title, str) else ""
errors = {}
if not re.fullmatch(r"\d+", project_id):
errors["project_id"] = "Invalid project"
if not 3 <= len(title) <= 80:
errors["title"] = "Title must be 3-80 characters"
if errors:
return fail("Fix the highlighted fields", errors)
project = deps.find_project(project_id)
if project is None or project["owner_id"] != user["id"]:
return fail("Project not found")
try:
deps.save_title(project_id, title)
except Exception:
return fail("Could not save, try again")
return {"status": "success", "message": "Saved", "field_errors": {}}

