Skip to Content
Grab N GoNext.js Data Mutations

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 disabled
Inline 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 tag
Redirect 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 level
Bind 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 show
Delete 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> too

Intermediate 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