Server Actions and forms

Next.js · Practical · 15 min

Server Actions are the App Router's built-in way to handle forms and mutations without writing API routes.

A Server Function is an async function marked with 'use server' that runs on the server but can be called from the client. Used for mutations (forms, button clicks) it is called a Server Action.

Define them in a file starting with 'use server' (all exports become actions), or inline inside a Server Component. Client Components can import actions from such a file but can't define them.

Pass an action to a form: <form action={createTodo}>. It receives the form's FormData. Forms with Server Actions work before JavaScript loads (progressive enhancement) and don't reload the page afterwards.

After mutating:

  • revalidatePath('/todos') or tag-based revalidation refreshes cached data,
  • refresh() from next/cache refreshes the current page's server data,
  • redirect('/todos/1') navigates (it throws, so call it last).

For pending state and errors, use React's `useActionState(action, initialState)` in a Client Component. It returns [state, formAction, pending]; the action receives (prevState, formData) and returns the new state, e.g. { error: 'Title is required' }.

Security: every Server Action is a public POST endpoint. Always validate input (e.g. with Zod) and check authentication and authorisation inside the action itself, just like an API route.

Example

// app/todos/actions.ts
'use server'
import { refresh } from 'next/cache'
import { db } from '@/lib/db'
import { getSession } from '@/lib/auth'

export async function addTodo(prev: { error?: string }, formData: FormData) {
  const session = await getSession()
  if (!session) return { error: 'Please log in' }          // check auth in every action
  const title = String(formData.get('title') ?? '').trim()
  if (!title) return { error: 'Title is required' }        // expected error as a value
  await db.todo.create({ title, userId: session.userId })
  refresh()                                                // re-render with fresh data
  return {}
}

// app/todos/todo-form.tsx
// 'use client'
// import { useActionState } from 'react'
// import { addTodo } from './actions'
// export function TodoForm() {
//   const [state, formAction, pending] = useActionState(addTodo, {})
//   return <form action={formAction}><input name="title" />
//     <button disabled={pending}>{pending ? 'Adding...' : 'Add'}</button>{state.error && <p>{state.error}</p>}</form>
// }

Remember

  • 'use server' functions run on the server and can be passed to <form action>
  • useActionState gives state, a wrapped action and a pending flag
  • After a mutation: revalidate, refresh() or redirect()
  • Every action is a public endpoint: validate input and check auth inside it

Watch out for

  • Relying on the UI hiding a button for authorisation; anyone can POST to the action directly.

Check yourself

  1. Why must a Server Action check the user's permissions itself even if the button is only shown to admins?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nextjs.org/docs/app/getting-started/mutating-data