Change data on the server straight from a form with Server Actions, show pending and error states, then revalidate or redirect.
Why: a Server Action is an async function that runs on the server but you can call from the client — perfect for creating, updating, or deleting data without writing a separate API. Mark it with the "use server" directive. Always check auth inside it, since it’s reachable directly.
// app/lib/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title')
const content = formData.get('content')
// ...check the user is allowed, then save to the database
}Why: pass the action to a <form action={...}>. On submit, Next.js sends the form’s FormData to your function in one server round-trip — no fetch, no onSubmit handler, and it even works before JavaScript loads.
import { createPost } from '@/app/lib/actions'
export function NewPostForm() {
return (
<form action={createPost}>
<input name="title" />
<textarea name="content" />
<button type="submit">Create</button>
</form>
)
}Why: while the action runs you want a spinner or disabled button. useActionState wraps the action and hands back a pending boolean. Note: this is a client hook, so it lives in a "use client" component.
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/lib/actions'
export function Form() {
const [state, formAction, pending] = useActionState(createPost, { message: '' })
return (
<form action={formAction}>
<input name="title" />
<button disabled={pending}>{pending ? 'Saving…' : 'Create'}</button>
</form>
)
}Why: for expected problems (a missing field, a bad value), don’t throw — return a plain object describing the error. useActionState exposes it as state so you can show a message. Reserve thrown errors for real bugs.
// app/lib/actions.ts
'use server'
export async function createPost(prevState: { message: string }, formData: FormData) {
const title = formData.get('title')
if (!title) {
return { message: 'Title is required' } // shown via state
}
// ...save
return { message: 'Saved!' }
}Why: after a mutation the page may show stale data. Refresh it with updateTag/revalidatePath, or send the user elsewhere with redirect(). Note: redirect() throws a special signal, so put it last — code after it won’t run.
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: { title: formData.get('title') } })
updateTag('posts') // refresh cached lists
redirect(`/posts/${post.id}`) // go to the new post
}