Skip to content

Next Server Actions

The 'use server' directive turns a function into an API endpoint without writing a route handler. Forms submit directly to the server. Mutations happen without building a REST API. It's elegant until someone sends you a malformed FormData that crashes your server

Server Actions are Next.js's answer to the age-old question "do I really need an API route for this?" For form submissions , button clicks , and any mutation that originates from user interaction , Server Actions are the right tool. For everything else , use route handlers

basic Server Action

Define a 'use server' function. Call it from a form

// src/app/contact/page.tsx
export default function ContactPage() {
  async function submitContact(formData: FormData) {
    'use server'

    const name = formData.get('name') as string
    const email = formData.get('email') as string
    const message = formData.get('message') as string

    // Server-side validation. This is where real validation lives
    if (!name || !email || !message) {
      throw new Error('All fields are required')
    }

    if (!email.includes('@')) {
      throw new Error('Invalid email')
    }

    // Save to database
    await prisma.contact.create({
      data: { name, email, message }
    })

    // Revalidate the page so the user sees updated state
    revalidatePath('/contact')
  }

  return (
    <form action={submitContact}>
      <input name="name" placeholder="Name" />
      <input name="email" placeholder="Email" type="email" />
      <textarea name="message" placeholder="Message" />
      <button type="submit">Send</button>
    </form>
  )
}

The 'use server' directive is required at the top of the function body. Without it , the function runs on the client and anyone can read it in the browser bundle

separate file pattern

For reusable Server Actions , put them in a separate file

// src/app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'

export async function createProduct(formData: FormData) {
  const name = formData.get('name') as string
  const price = parseFloat(formData.get('price') as string)

  if (!name || isNaN(price)) {
    return { error: 'Invalid input' }
  }

  await prisma.product.create({ data: { name, price } })
  revalidatePath('/products')

  return { success: true }
}

export async function deleteProduct(id: string) {
  await prisma.product.delete({ where: { id } })
  revalidatePath('/products')
}
// src/app/products/page.tsx
import { createProduct, deleteProduct } from './actions'

export default function ProductsPage() {
  return (
    <form action={createProduct}>
      <input name="name" placeholder="Name" />
      <input name="price" placeholder="Price" type="number" step="0.01" />
      <button type="submit">Create</button>
    </form>
  )
}

Server Actions in separate files are importable from any page or component. Mark the entire file with 'use server' at the top

Client Component calling Server Actions

You can pass Server Actions to Client Components without making them 'use server' themselves

// src/app/todos/page.tsx
import { toggleTodo } from './actions'
import TodoList from './TodoList'

export default function TodosPage() {
  return <TodoList toggleTodo={toggleTodo} />
}
// src/app/todos/TodoList.tsx
'use client'

export default function TodoList({
  toggleTodo
}: {
  toggleTodo: (id: string) => Promise<void>
}) {
  async function handleToggle(id: string) {
    // This calls the server action
    await toggleTodo(id)
  }

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <span>{todo.title}</span>
          <button onClick={() => handleToggle(todo.id)}>
            {todo.completed ? 'Undo' : 'Complete'}
          </button>
        </li>
      ))}
    </ul>
  )
}

The Server Action is passed as a prop. The Client Component calls it like a regular async function. The server code never ships to the browser

mutation patterns

After a mutation , you need to refresh the data. Three options:

'use server'
import { revalidatePath, revalidateTag, redirect } from 'next/cache'

export async function updateProduct(id: string, formData: FormData) {
  // 1. update database
  await prisma.product.update({
    where: { id },
    data: { name: formData.get('name') as string }
  })

  // 2. revalidate specific path
  revalidatePath('/products')
  revalidatePath(`/products/${id}`)

  // 3. OR revalidate by tag
  revalidateTag('products')

  // 4. redirect to another page
  redirect('/products')
}

redirect() throws an error internally (it uses next/navigation under the hood). Call it after revalidation , not before

loading and error states

Server Actions support useActionState for tracking pending state

'use client'

import { useActionState } from 'react'
import { createProduct } from './actions'

const initialState = { error: '', success: false }

export default function ProductForm() {
  const [state, formAction, pending] = useActionState(createProduct, initialState)

  return (
    <form action={formAction}>
      <input name="name" placeholder="Name" disabled={pending} />
      <input name="price" placeholder="Price" disabled={pending} />
      <button type="submit" disabled={pending}>
        {pending ? 'Creating...' : 'Create Product'}
      </button>
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">Product created!</p>}
    </form>
  )
}

useActionState gives you pending state and the return value from the Server Action. No useState , no useEffect , no manual loading states

security notes

Server Actions have security guardrails but they're not a silver bullet

Built-in CSRF protection: Next.js generates a cryptographically signed token for every Server Action. Forms that submit without the correct token get rejected. This prevents cross-site request forgery attacks

What's NOT protected: * No rate limiting - nothing stops a bot from submitting your form 10,000 times. Implement rate limiting yourself * No input validation - Server Actions don't validate input by default. You write the validation * No auth enforcement - any page that imports a Server Action can call it. Protect actions behind auth checks * Error messages - throw an error with a sensitive message and it might leak stack traces

Always validate and auth-check inside the Server Action:

'use server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'

export async function deleteAccount(userId: string) {
  const session = await getServerSession(authOptions)

  // Auth check - inside the action , not in the UI
  if (!session || session.user.id !== userId) {
    throw new Error('Unauthorized')
  }

  await prisma.user.delete({ where: { id: userId } })
  revalidatePath('/admin/users')
}

prerequisites

next_06_data_fetching.md - you should understand data fetching patterns and revalidation


next → next_08_api_routes.md