Next.js Server Actions: How to Handle Mutations Without an API Layer
Use Next.js Server Actions with forms, validation, authorization, and error handling, with implementation examples.
Server Actions let a Next.js form call a server function for a mutation. They reduce client request wiring, but the server still needs to validate input, check authorization, handle errors, and refresh affected cached data.
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.
Treat each action as a server entry point. Test invalid input, unauthorized requests, failed writes, and the UI state after a successful mutation.