Next.js Data Mutations
Targeting Next.js 16 (App Router)
Syntax
Server Action
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const body = formData.get('body') as string
await db.post.create({ data: { title, body } })
}Form Action
import { createPost } from './actions'
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="body" required />
<button type="submit">Create</button>
</form>
)
}
// no client JS needed — works with JS disabledInline Action
export default function Page() {
async function handleSubmit(formData: FormData) {
'use server'
const name = formData.get('name') as string
await db.user.update({
where: { id: userId },
data: { name },
})
}
return (
<form action={handleSubmit}>
<input name="name" />
<button type="submit">Save</button>
</form>
)
}Return Data
'use server'
export async function login(formData: FormData) {
const email = formData.get('email') as string
const user = await db.user.findUnique({ where: { email } })
if (!user) {
return { error: 'User not found' }
}
// set cookie, redirect, etc.
return { success: true }
}Revalidate After
'use server'
import { revalidatePath, revalidateTag }
from 'next/cache'
export async function addComment(formData: FormData) {
await db.comment.create({ data: { /* ... */ } })
// refresh specific page
revalidatePath('/blog/my-post')
// stale-while-revalidate by tag
revalidateTag('comments')
// optional: pass a cacheLife profile
revalidateTag('comments', 'max')
}updateTag + refresh
'use server'
import { updateTag, refresh } from 'next/cache'
export async function updateProfile(formData: FormData) {
const name = formData.get('name') as string
await db.user.update({ where: { id: userId }, data: { name } })
// read-your-writes — user sees change immediately
updateTag('profile')
}
export async function markRead(id: string) {
await db.notification.markAsRead(id)
// refresh client router (re-run server components)
refresh()
}
// updateTag = expire + refetch in same request
// refresh = re-render without a specific tagRedirect After
'use server'
import { redirect } from 'next/navigation'
export async function createProject(formData: FormData) {
const name = formData.get('name') as string
const project = await db.project.create({
data: { name },
})
redirect(`/projects/${project.id}`)
// redirect must be called outside try/catch
}Beginner Patterns
useActionState
'use client'
import { useActionState } from 'react'
import { login } from './actions'
function LoginForm() {
const [state, action, pending] = useActionState(login, null)
return (
<form action={action}>
<input name="email" />
<button disabled={pending}>
{pending ? 'Logging in...' : 'Log in'}
</button>
{state?.error && <p>{state.error}</p>}
</form>
)
}useFormStatus
'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending}>
{pending ? 'Saving...' : 'Save'}
</button>
)
}
// must be a child of <form> — doesn't work at the form levelBind Arguments
// pass extra data to a server action
import { deletePost } from './actions'
export function PostCard({ id }: { id: string }) {
const deleteWithId = deletePost.bind(null, id)
return (
<form action={deleteWithId}>
<button type="submit">Delete</button>
</form>
)
}// actions.ts
'use server'
export async function deletePost(id: string) {
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}Hidden Fields
export function EditForm({ post }: { post: Post }) {
return (
<form action={updatePost}>
<input type="hidden" name="id" value={post.id} />
<input name="title" defaultValue={post.title} />
<button type="submit">Update</button>
</form>
)
}
// hidden inputs pass data the form doesn't showDelete with Action
import { deleteItem } from './actions'
export function DeleteButton({ id }: { id: string }) {
return (
<form action={deleteItem}>
<input type="hidden" name="id" value={id} />
<button type="submit">Delete</button>
</form>
)
}'use server'
export async function deleteItem(formData: FormData) {
const id = formData.get('id') as string
await db.item.delete({ where: { id } })
revalidatePath('/items')
}Call from Event
'use client'
import { toggleLike } from './actions'
export function LikeButton({ postId }: { postId: string }) {
async function handleClick() {
await toggleLike(postId)
}
return <button onClick={handleClick}>Like</button>
}
// server actions work outside <form> tooIntermediate Patterns
Validate with Zod
'use server'
import { z } from 'zod'
const Schema = z.object({
title: z.string().min(1, 'Required'),
body: z.string().min(10, 'Too short'),
})
export async function createPost(formData: FormData) {
const result = Schema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
})
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
await db.post.create({ data: result.data })
revalidatePath('/posts')
}Form + Validation UI
'use client'
import { useActionState } from 'react'
import { createPost } from './actions'
function PostForm() {
const [state, action, pending] = useActionState(createPost, null)
return (
<form action={action}>
<input name="title" />
{state?.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
<textarea name="body" />
{state?.errors?.body && (
<p className="error">{state.errors.body[0]}</p>
)}
<button disabled={pending}>Create</button>
</form>
)
}Optimistic Update
'use client'
import { useOptimistic } from 'react'
import { addTodo } from './actions'
function TodoList({ todos }: { todos: Todo[] }) {
const [optimistic, addOptimistic] = useOptimistic(
todos,
(state, newTodo: string) => [
...state,
{ id: crypto.randomUUID(), text: newTodo, pending: true },
]
)
return (
<form action={async (fd) => {
const text = fd.get('text') as string
addOptimistic(text) // show immediately
await addTodo(fd) // persist on server
}}>
<input name="text" />
<button type="submit">Add</button>
</form>
)
}File Upload
'use server'
export async function uploadAvatar(formData: FormData) {
const file = formData.get('avatar') as File
if (!file || file.size === 0) {
return { error: 'No file selected' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
await fs.writeFile(`/uploads/${file.name}`, buffer)
revalidatePath('/profile')
}Multiple Actions
import { publish, unpublish, deletePost }
from './actions'
export function PostActions({ id }: { id: string }) {
return (
<div>
<form action={publish.bind(null, id)}>
<button>Publish</button>
</form>
<form action={unpublish.bind(null, id)}>
<button>Unpublish</button>
</form>
<form action={deletePost.bind(null, id)}>
<button>Delete</button>
</form>
</div>
)
}Auth Check in Action
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
async function getSession() {
const token = (await cookies()).get('token')?.value
if (!token) redirect('/login')
return verifyToken(token)
}
export async function updateProfile(formData: FormData) {
const session = await getSession()
const name = formData.get('name') as string
await db.user.update({
where: { id: session.userId },
data: { name },
})
revalidatePath('/profile')
}Advanced Patterns
Typed State Machine
'use server'
type State =
| { status: 'idle' }
| { status: 'error'; errors: Record<string, string[]> }
| { status: 'success'; message: string }
export async function submitForm(
_prev: State,
formData: FormData
): Promise<State> {
const result = Schema.safeParse(Object.fromEntries(formData))
if (!result.success) {
return { status: 'error',
errors: result.error.flatten().fieldErrors }
}
await db.form.create({ data: result.data })
return { status: 'success', message: 'Saved!' }
}Transaction
'use server'
export async function transferFunds(formData: FormData) {
const from = formData.get('from') as string
const to = formData.get('to') as string
const amount = Number(formData.get('amount'))
await db.$transaction(async (tx) => {
await tx.account.update({
where: { id: from },
data: { balance: { decrement: amount } },
})
await tx.account.update({
where: { id: to },
data: { balance: { increment: amount } },
})
})
revalidatePath('/accounts')
}Rate Limiting
'use server'
import { headers } from 'next/headers'
const rateLimit = new Map<string, number[]>()
export async function submitContact(formData: FormData) {
const ip = (await headers()).get('x-forwarded-for') ?? 'unknown'
const now = Date.now()
const window = rateLimit.get(ip)?.filter(
(t) => now - t < 60_000
) ?? []
if (window.length >= 5) {
return { error: 'Too many requests' }
}
rateLimit.set(ip, [...window, now])
// ... process form
}Streaming Response
// app/api/stream/route.ts
export async function POST(req: Request) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (const chunk of ['Processing', 'Almost', 'Done']) {
controller.enqueue(
encoder.encode(`data: ${chunk}\n\n`)
)
await new Promise((r) => setTimeout(r, 1000))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
})
}Middleware + Action
'use server'
type ActionFn<T> = (formData: FormData) => Promise<T>
function withAuth<T>(action: ActionFn<T>): ActionFn<T> {
return async (formData) => {
const session = await getSession()
if (!session) redirect('/login')
return action(formData)
}
}
// wrap any action
export const createPost = withAuth(async (formData) => {
const title = formData.get('title') as string
await db.post.create({ data: { title } })
revalidatePath('/posts')
})Parallel Mutations
'use client'
import { useTransition } from 'react'
import { updateName, updateEmail } from './actions'
function ProfileForm({ user }: { user: User }) {
const [pending, startTransition] = useTransition()
async function handleSubmit(formData: FormData) {
startTransition(async () => {
await Promise.all([
updateName(formData),
updateEmail(formData),
])
})
}
return (
<form action={handleSubmit}>
<input name="name" defaultValue={user.name} />
<input name="email" defaultValue={user.email} />
<button disabled={pending}>Save All</button>
</form>
)
}Last updated on