# Next.js Server Actions: How to Handle Mutations Without an API Layer

> A practical guide to Next.js Server Actions — how to write them, wire them to forms, handle validation and errors, and avoid the common pitfalls that break production apps.

- **Published:** 2026-01-21
- **Author:** Clixo
- **Reading time:** 6 min read
- **Tags:** next.js, server-actions, mutations, forms, app-router
- **Canonical URL:** https://clixo.sh/blog/nextjs-server-actions-guide

Building a form in Next.js used to mean writing an API route, a fetch call from the client, loading state management, and error handling — all for a simple create or update operation. Server Actions collapse that stack. You write a server-side function, wire it to a form, and the mutation happens on the server without an explicit API endpoint. Done correctly, this is one of the most developer-friendly patterns in the App Router. Done carelessly, it becomes a source of subtle security issues and confusing state bugs.

This guide covers how Server Actions work, how to use them well, and what to watch out for.

## What Server Actions Are

A Server Action is an `async` function marked with the `'use server'` directive. When called from a client component or a form, Next.js serializes the call, sends it as a POST request to the server, runs the function, and returns the result.

The `'use server'` directive can appear at the top of a file (making every export a Server Action) or at the top of an individual function body.

```typescript
// app/actions/products.ts
'use server'

import { db } from '@/lib/db'
import { revalidateTag } from 'next/cache'

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

  await db.products.create({ data: { name, price } })
  revalidateTag('products')
}
```

```mermaid
sequenceDiagram
  participant U as User
  participant F as Form
  participant SA as "Server Action"
  participant DB as Database
  participant CA as Cache
  U->>F: Submit form
  F->>SA: POST FormData
  SA->>SA: Validate with Zod
  alt Validation fails
    SA-->>F: Return field errors
    F-->>U: Show inline errors
  else Validation passes
    SA->>DB: Write record
    DB-->>SA: Success
    SA->>CA: revalidateTag
    SA-->>F: Return success
    F-->>U: Redirect or success state
  end
```

## Wiring to a Form

The simplest usage is the `action` attribute on a form element. No `onSubmit` handler, no `fetch`, no state:

```typescript
// app/products/new/page.tsx
import { createProduct } from '@/app/actions/products'

export default function NewProductPage() {
  return (
    <form action={createProduct}>
      <input name="name" type="text" required />
      <input name="price" type="number" required />
      <button type="submit">Create product</button>
    </form>
  )
}
```

When the form is submitted, `createProduct` receives the `FormData`. This works without JavaScript — the form submits as a native POST request and the action runs on the server. Progressive enhancement by default.

## Adding Validation

Never trust `FormData` directly in production. Validate input before writing to the database. Zod is the standard choice for this:

```typescript
'use server'

import { z } from 'zod'

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

export async function createProduct(formData: FormData) {
  const result = ProductSchema.safeParse({
    name: formData.get('name'),
    price: formData.get('price'),
  })

  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors }
  }

  await db.products.create({ data: result.data })
  revalidateTag('products')
  return { success: true }
}
```

The action returns an object rather than throwing. The calling component reads the return value to decide what to show.

## Handling State in Client Components

For forms that need loading states, error messages, or optimistic updates, use the `useActionState` hook (formerly `useFormState`). It wraps a Server Action and gives you the action's return value as state.

```typescript
'use client'

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

export function ProductForm() {
  const [state, action, isPending] = useActionState(createProduct, null)

  return (
    <form action={action}>
      <input name="name" type="text" />
      {state?.errors?.name && (
        <p className="error">{state.errors.name[0]}</p>
      )}
      <input name="price" type="number" />
      {state?.errors?.price && (
        <p className="error">{state.errors.price[0]}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create product'}
      </button>
    </form>
  )
}
```

`isPending` is `true` while the action is executing. `state` holds the return value from the previous action call. This is all the state management most forms need.

## Redirecting After Success

For forms that should navigate after a successful submission — a create form that takes you to the new item's detail page — call `redirect()` from `next/navigation` at the end of the Server Action.

```typescript
import { redirect } from 'next/navigation'

export async function createProduct(formData: FormData) {
  // ... validate and create ...
  const product = await db.products.create({ data: result.data })
  redirect(`/products/${product.id}`)
}
```

`redirect()` throws a special error internally that Next.js catches and handles as a navigation. Do not wrap it in a `try/catch` — that will suppress the redirect.

## Cache Invalidation After Mutations

Server Actions that write data need to invalidate the cache. Without this, the page that reads the same data will show stale results after the mutation.

Two mechanisms:

- `revalidatePath('/products')` — invalidates all cached output for that path
- `revalidateTag('products')` — invalidates all fetch calls and `unstable_cache` results tagged with `'products'`

Tag-based invalidation is more precise and works well when the same data is referenced across multiple routes. Apply tags at the point of caching and invalidate by tag in the mutation.

## Security Considerations

Server Actions are exposed as POST endpoints. They are publicly reachable if someone knows the endpoint URL. This means:

**Always authenticate inside the action.** Do not rely on route protection alone. If the action modifies user data, verify the current session at the start of the function.

```typescript
export async function updateProfile(formData: FormData) {
  const session = await getSession()
  if (!session?.user) throw new Error('Unauthorized')

  // proceed with the mutation
}
```

**Always validate input.** Form data arrives as strings and can contain anything. Parse and validate every field before touching the database.

**Do not expose internal IDs blindly.** If the action accepts a record ID, verify the current user has permission to modify that record before updating it.

## When Not to Use Server Actions

Server Actions are designed for mutations triggered by user interaction. They are not appropriate for:

- General-purpose data fetching (use server components for reads)
- WebSocket or streaming use cases
- Polling from a client component on an interval

For complex API requirements — file uploads to external storage, webhook handling, or endpoints consumed by third-party services — Route Handlers (`route.ts` files) are the right tool. They give you full control over request and response handling.

---

Server Actions simplify the mutation layer in Next.js applications dramatically. With validation, authentication, and cache invalidation in place, they produce a clean, type-safe, and secure path from form submission to database write.

If your team is building a full-stack Next.js product and wants engineering support on the architecture, [talk to Clixo](https://clixo.sh/#contact). We design and ship complete Next.js systems for founders and product teams.

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
