Next.js Server Actions: How to Handle Mutations Without an API Layer
A practical guide to Next.js Server Actions — how to write them, wire them to forms, handle validation and errors, and avoid the common pitfalls that break production apps.
Building a form in Next.js used to mean writing an API route, a fetch call from the client, loading state management, and error handling — all for a simple create or update operation. Server Actions collapse that stack. You write a server-side function, wire it to a form, and the mutation happens on the server without an explicit API endpoint. Done correctly, this is one of the most developer-friendly patterns in the App Router. Done carelessly, it becomes a source of subtle security issues and confusing state bugs.
This guide covers how Server Actions work, how to use them well, and what to watch out for.
What Server Actions Are
A Server Action is an async function marked with the 'use server' directive. When called from a client component or a form, Next.js serializes the call, sends it as a POST request to the server, runs the function, and returns the result.
The 'use server' directive can appear at the top of a file (making every export a Server Action) or at the top of an individual function body.
// app/actions/products.ts
'use server'
import { db } from '@/lib/db'
import { revalidateTag } from 'next/cache'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
const price = Number(formData.get('price'))
await db.products.create({ data: { name, price } })
revalidateTag('products')
}Wiring to a Form
The simplest usage is the action attribute on a form element. No onSubmit handler, no fetch, no state:
// app/products/new/page.tsx
import { createProduct } from '@/app/actions/products'
export default function NewProductPage() {
return (
<form action={createProduct}>
<input name="name" type="text" required />
<input name="price" type="number" required />
<button type="submit">Create product</button>
</form>
)
}When the form is submitted, createProduct receives the FormData. This works without JavaScript — the form submits as a native POST request and the action runs on the server. Progressive enhancement by default.
Adding Validation
Never trust FormData directly in production. Validate input before writing to the database. Zod is the standard choice for this:
'use server'
import { z } from 'zod'
const ProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.coerce.number().positive(),
})
export async function createProduct(formData: FormData) {
const result = ProductSchema.safeParse({
name: formData.get('name'),
price: formData.get('price'),
})
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
await db.products.create({ data: result.data })
revalidateTag('products')
return { success: true }
}The action returns an object rather than throwing. The calling component reads the return value to decide what to show.
Handling State in Client Components
For forms that need loading states, error messages, or optimistic updates, use the useActionState hook (formerly useFormState). It wraps a Server Action and gives you the action's return value as state.
'use client'
import { useActionState } from 'react'
import { createProduct } from '@/app/actions/products'
export function ProductForm() {
const [state, action, isPending] = useActionState(createProduct, null)
return (
<form action={action}>
<input name="name" type="text" />
{state?.errors?.name && (
<p className="error">{state.errors.name[0]}</p>
)}
<input name="price" type="number" />
{state?.errors?.price && (
<p className="error">{state.errors.price[0]}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create product'}
</button>
</form>
)
}isPending is true while the action is executing. state holds the return value from the previous action call. This is all the state management most forms need.
Redirecting After Success
For forms that should navigate after a successful submission — a create form that takes you to the new item's detail page — call redirect() from next/navigation at the end of the Server Action.
import { redirect } from 'next/navigation'
export async function createProduct(formData: FormData) {
// ... validate and create ...
const product = await db.products.create({ data: result.data })
redirect(`/products/${product.id}`)
}redirect() throws a special error internally that Next.js catches and handles as a navigation. Do not wrap it in a try/catch — that will suppress the redirect.
Cache Invalidation After Mutations
Server Actions that write data need to invalidate the cache. Without this, the page that reads the same data will show stale results after the mutation.
Two mechanisms:
revalidatePath('/products')— invalidates all cached output for that pathrevalidateTag('products')— invalidates all fetch calls andunstable_cacheresults tagged with'products'
Tag-based invalidation is more precise and works well when the same data is referenced across multiple routes. Apply tags at the point of caching and invalidate by tag in the mutation.
Security Considerations
Server Actions are exposed as POST endpoints. They are publicly reachable if someone knows the endpoint URL. This means:
Always authenticate inside the action. Do not rely on route protection alone. If the action modifies user data, verify the current session at the start of the function.
export async function updateProfile(formData: FormData) {
const session = await getSession()
if (!session?.user) throw new Error('Unauthorized')
// proceed with the mutation
}Always validate input. Form data arrives as strings and can contain anything. Parse and validate every field before touching the database.
Do not expose internal IDs blindly. If the action accepts a record ID, verify the current user has permission to modify that record before updating it.
When Not to Use Server Actions
Server Actions are designed for mutations triggered by user interaction. They are not appropriate for:
- General-purpose data fetching (use server components for reads)
- WebSocket or streaming use cases
- Polling from a client component on an interval
For complex API requirements — file uploads to external storage, webhook handling, or endpoints consumed by third-party services — Route Handlers (route.ts files) are the right tool. They give you full control over request and response handling.
Server Actions simplify the mutation layer in Next.js applications dramatically. With validation, authentication, and cache invalidation in place, they produce a clean, type-safe, and secure path from form submission to database write.
If your team is building a full-stack Next.js product and wants engineering support on the architecture, talk to Clixo. We design and ship complete Next.js systems for founders and product teams.