Chapter 11

Server Actions: The New Paradigm for Forms and Data Mutations

Rethinking the Form Submission

Web form submission has passed through three distinct eras: plain HTML forms (full-page reloads), the Ajax era (XMLHttpRequest / fetch, where JavaScript intercepts the submit event), and now Server Actions โ€” which preserve the progressive enhancement of the first era while delivering full TypeScript type safety and server-side execution. Understanding why Next.js invented Server Actions requires understanding what was wrong with the alternatives.

The Ajax approach requires you to write a handleSubmit function, call fetch('/api/products', { method: 'POST', body: JSON.stringify(data) }), handle errors, update state, and revalidate any cached data. It works, but it scatters mutation logic across an API route and client-side event handler, with no type safety spanning the boundary. Server Actions collapse this into a single function with a 'use server' directive.

Two Ways to Declare 'use server'

File-Level Directive

Placing 'use server' at the top of a file marks every exported async function in that file as a Server Action:

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

import { prisma } from '@/lib/prisma'
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'

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

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

export async function deleteProduct(id: string) {
  await prisma.product.delete({ where: { id } })
  revalidateTag('products')
}

Inline Directive

Inside a Server Component, you can mark an individual function as a Server Action:

// app/products/new/page.tsx (Server Component)
export default function NewProductPage() {
  async function createProduct(formData: FormData) {
    'use server' // inline directive โ€” scoped to this function only
    const name = formData.get('name') as string
    await prisma.product.create({ data: { name } })
    redirect('/products')
  }

  return (
    <form action={createProduct}>
      <input name="name" type="text" required />
      <button type="submit">Create</button>
    </form>
  )
}

Inline directives are suitable for simple one-off actions. File-level directives are preferable for business logic that needs to be reused across multiple components.

Progressive Enhancement: Works Without JavaScript

This is the most underappreciated feature of Server Actions. When you bind a Server Action to <form action={...}>, the form works correctly even when JavaScript has not loaded or is disabled:

export default function ContactForm() {
  async function sendMessage(formData: FormData) {
    'use server'
    const message = formData.get('message') as string
    await sendEmail(message)
    redirect('/thanks')
  }

  return (
    <form action={sendMessage}>
      <textarea name="message" rows={5} required />
      <button type="submit">Send</button>
    </form>
  )
}

When the browser submits this form, it serializes the form fields as multipart/form-data and sends a POST request to Next.js's internal endpoint. Next.js deserializes the payload and invokes the Server Action. JavaScript is progressive enhancement layered on top, not a prerequisite.

This matters for accessibility, search engine crawlability, and reliability on flaky mobile connections where JavaScript may time out. The core functionality works on the web's bedrock HTTP semantics.

useActionState: Managing Form State

Real forms need to handle loading states, validation errors, and success feedback. The useActionState hook was designed exactly for this:

// app/products/new/page.tsx
'use client'

import { useActionState } from 'react'
import { createProduct } from '@/app/actions/product'

type ActionState = {
  error?: string
  success?: boolean
}

export default function NewProductForm() {
  const [state, formAction, isPending] = useActionState<ActionState, FormData>(
    createProduct,
    { error: undefined, success: false } // initial state
  )

  return (
    <form action={formAction}>
      {state.error && (
        <div role="alert" className="error">{state.error}</div>
      )}
      {state.success && (
        <div className="success">Product created successfully!</div>
      )}
      <input name="name" type="text" required disabled={isPending} />
      <input name="price" type="number" step="0.01" required disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Product'}
      </button>
    </form>
  )
}

The corresponding Server Action must accept prevState as its first parameter when used with useActionState:

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

import { z } from 'zod'

const ProductSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100),
  price: z.coerce.number().positive('Price must be greater than 0'),
})

type ActionState = { error?: string; success?: boolean }

export async function createProduct(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const raw = {
    name: formData.get('name'),
    price: formData.get('price'),
  }

  const parsed = ProductSchema.safeParse(raw)
  if (!parsed.success) {
    return { error: parsed.error.errors[0].message }
  }

  try {
    await prisma.product.create({ data: parsed.data })
    revalidateTag('products')
    return { success: true }
  } catch {
    return { error: 'Database write failed. Please try again.' }
  }
}

The three values returned by useActionState:

Zod Validation: Field-Level Error Messages

The single-string error approach works for simple cases. Production forms typically need per-field error display:

type FieldErrors = {
  name?: string[]
  price?: string[]
  _form?: string[]  // form-level errors (auth failures, server errors)
}

type ActionState = { errors?: FieldErrors; success?: boolean }

export async function createProduct(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const parsed = ProductSchema.safeParse({
    name: formData.get('name'),
    price: formData.get('price'),
  })

  if (!parsed.success) {
    return {
      errors: parsed.error.flatten().fieldErrors as FieldErrors,
    }
  }

  try {
    await prisma.product.create({ data: parsed.data })
    revalidateTag('products')
    return { success: true }
  } catch {
    return { errors: { _form: ['Server error. Please try again later.'] } }
  }
}

Using field-level errors in the form:

<div>
  <label htmlFor="name">Product Name</label>
  <input
    id="name"
    name="name"
    aria-describedby="name-error"
    aria-invalid={!!state.errors?.name}
  />
  {state.errors?.name && (
    <p id="name-error" role="alert" className="text-red-500 text-sm">
      {state.errors.name[0]}
    </p>
  )}
</div>

The aria-describedby / aria-invalid attributes connect the error message to the input for screen readers โ€” a critical accessibility concern that belongs in this pattern by default.

Security: Built-In CSRF Protection and Mandatory Input Validation

Server Actions include two important security mechanisms:

CSRF Protection: Next.js automatically adds an Origin check to all Server Action requests. The request must originate from the same origin as the application. Cross-origin requests from malicious sites are rejected at the framework level, with no manual CSRF token management required.

Never trust client-provided data: Despite the CSRF protection, data arriving in formData must be treated as untrusted user input. Always validate server-side and always verify authorization:

export async function updateProduct(
  id: string,
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // Step 1: Authenticate โ€” get the current user from the server session
  const session = await getServerSession()
  if (!session?.user) {
    return { errors: { _form: ['Please sign in first'] } }
  }

  // Step 2: Authorize โ€” verify this user owns the product
  // NEVER skip this check. The id came from the client and cannot be trusted.
  const product = await prisma.product.findUnique({
    where: { id },
    select: { ownerId: true },
  })

  if (!product || product.ownerId !== session.user.id) {
    return { errors: { _form: ['Permission denied'] } }
  }

  // Step 3: Validate โ€” parse and validate the form data
  const parsed = ProductSchema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors as FieldErrors }
  }

  // Step 4: Mutate โ€” only now do we write to the database
  await prisma.product.update({ where: { id }, data: parsed.data })
  revalidatePath(`/products/${id}`)
  return { success: true }
}

The authenticate โ†’ authorize โ†’ validate โ†’ mutate order is non-negotiable. Swapping these steps creates security vulnerabilities.

Full CRUD Example: Product Management

Putting it all together into a complete product management system:

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

import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { getServerSession } from 'next-auth'

const ProductSchema = z.object({
  name: z.string().min(1).max(100),
  price: z.coerce.number().positive(),
  description: z.string().optional(),
})

type State = { errors?: Record<string, string[]>; message?: string }

export async function createProduct(_: State, formData: FormData): Promise<State> {
  const session = await getServerSession()
  if (!session) return { errors: { _form: ['Unauthorized'] } }

  const parsed = ProductSchema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }

  await prisma.product.create({
    data: { ...parsed.data, ownerId: session.user.id },
  })
  revalidateTag('products')
  redirect('/products')
}

export async function updateProduct(
  id: string,
  _: State,
  formData: FormData
): Promise<State> {
  const session = await getServerSession()
  if (!session) return { errors: { _form: ['Unauthorized'] } }

  const existing = await prisma.product.findUnique({ where: { id } })
  if (existing?.ownerId !== session.user.id) {
    return { errors: { _form: ['Forbidden'] } }
  }

  const parsed = ProductSchema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }

  await prisma.product.update({ where: { id }, data: parsed.data })
  revalidatePath(`/products/${id}`)
  revalidateTag('products')
  return { message: 'Product updated successfully' }
}

// Delete โ€” uses .bind() to pre-supply the id
export async function deleteProduct(id: string) {
  const session = await getServerSession()
  if (!session) throw new Error('Unauthorized')

  const existing = await prisma.product.findUnique({ where: { id } })
  if (existing?.ownerId !== session.user.id) throw new Error('Forbidden')

  await prisma.product.delete({ where: { id } })
  revalidateTag('products')
  redirect('/products')
}

Using pre-bound parameters in a component:

// app/products/[id]/page.tsx
import { deleteProduct } from '@/app/actions/product'

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id)
  const deleteWithId = deleteProduct.bind(null, params.id) // bind id at render time

  return (
    <div>
      <h1>{product.name}</h1>
      <form action={deleteWithId}>
        <button type="submit">Delete Product</button>
      </form>
    </div>
  )
}

.bind(null, params.id) is the idiomatic pattern for pre-supplying arguments to Server Actions. The bound function's remaining signature matches what a form action expects, making it usable as action={deleteWithId} directly.

Cache Invalidation After Mutations

Every mutation must invalidate the relevant caches, or users will see stale data:

One important trap: redirect() works by throwing a special error internally. If you call it inside a try/catch block, the catch clause may swallow the redirect. Always call revalidateTag / revalidatePath first, then redirect() at the end of the function outside any try/catch.

Summary

Server Actions unify three concerns that were previously scattered across client-side event handlers, API routes, and data fetching logic: the form submission mechanism (progressive enhancement via native HTML form semantics), input validation (Zod running on the server where it cannot be bypassed), and cache invalidation (integrated directly into the mutation via revalidateTag). The result is that a complete create-validate-persist-invalidate-redirect flow fits in a single TypeScript function, fully type-safe from the form field to the database.

Rate this chapter
4.8  / 5  (29 ratings)

๐Ÿ’ฌ Comments