EmpireUI
Get Pro
← Blog8 min read#server actions#next.js#forms

Next.js Server Actions in 2026: Forms, Mutations and the Right Patterns

Server Actions matured fast. Here's how forms, mutations, and optimistic updates actually work in Next.js 15+ — with patterns that don't fall apart under real traffic.

Developer writing Next.js server-side code on a dark-themed monitor

Where Server Actions Actually Stand in 2026

Server Actions shipped stable in Next.js 14, but it's 2026 now and the ecosystem has had time to produce real opinions. Not just hello-world opinions — production ones. The patterns that held up and the ones that quietly exploded in staging.

Honestly, the first year was rough. Docs were thin, error handling was underspecified, and too many tutorials showed you a <form action={myAction}> example without explaining what happens when the action throws, when the network times out, or when you need to mutate the same data from three different components.

That said, the core primitive is genuinely good. You get a POST request to your own server, typed end-to-end, without writing an API route. No fetch boilerplate. No res.json(). Just a function that runs on the server and returns whatever you need. In 2025 alone, the Next.js team shipped meaningful improvements to error propagation and useFormState ergonomics — so if you wrote these off early, it's worth another look.

This article covers what the patterns actually look like in a real codebase, which abstractions are worth building, and where you should still reach for a plain API route instead.

The Basic Form Pattern — and What People Get Wrong

Start with the simplest case. A form that creates a record. You've seen this in every Server Actions tutorial since Next.js 14.0.

// app/actions/create-post.ts
'use server'

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

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const body = formData.get('body') as string

  if (!title || title.length < 3) {
    return { error: 'Title must be at least 3 characters' }
  }

  await db.post.create({ data: { title, body } })
  revalidatePath('/posts')
}

Then in your component: ``tsx // app/posts/new/page.tsx import { createPost } from '@/actions/create-post' export default function NewPostPage() { return ( <form action={createPost}> <input name="title" type="text" placeholder="Post title" /> <textarea name="body" placeholder="Write something..." /> <button type="submit">Publish</button> </form> ) } ``

This works. But notice the problem: the action returns { error: '...' } and the component has no idea. The form submits, succeeds or fails silently, and the user stares at the same page. That return value goes nowhere.

The fix is useActionState (previously useFormState before React 19 renamed it). This is the piece most tutorials skip because it requires marking the component as a Client Component — which feels weird when you're trying to keep things server-side. In practice, that discomfort is the correct mental model: the *action* runs on the server, but *reacting to its result* is inherently client-side state.

useActionState: Handling Errors and Feedback Properly

useActionState is the right tool for forms that need to show validation errors or success messages without a full navigation. It wraps your action and gives the component access to the last returned value.

'use client'

import { useActionState } from 'react'
import { createPost } from '@/actions/create-post'

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

export function NewPostForm() {
  const [state, action, isPending] = useActionState<ActionState, FormData>(
    createPost,
    null
  )

  return (
    <form action={action}>
      {state?.error && (
        <p className="text-red-500 text-sm">{state.error}</p>
      )}
      {state?.success && (
        <p className="text-green-500 text-sm">Post published!</p>
      )}
      <input name="title" type="text" placeholder="Post title" />
      <textarea name="body" placeholder="Write something..." />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Publishing...' : 'Publish'}
      </button>
    </form>
  )
}

Your action signature changes slightly — it now receives prevState as its first argument: ``tsx 'use server' export async function createPost( prevState: ActionState, formData: FormData ): Promise<ActionState> { const title = formData.get('title') as string if (!title || title.length < 3) { return { error: 'Title must be at least 3 characters' } } await db.post.create({ data: { title } }) revalidatePath('/posts') return { success: true } } ``

Worth noting: isPending from useActionState is the correct way to show loading state. Don't reach for a separate useState boolean — you'll just end up with two sources of truth that drift apart.

One more thing — useFormStatus exists too, but it only works *inside* the form element, in a child component. If you're building a shared <SubmitButton> component, that's actually the right hook. useActionState lives in the parent form wrapper; useFormStatus lives inside the submit button itself.

Optimistic Updates Without a State Management Library

Here's the pattern most people reach for a library to solve. You click a like button. You want the count to go up immediately, then confirm (or roll back) after the server responds. Classic optimistic UI.

React 19 shipped useOptimistic for exactly this. No Redux, no Zustand, no SWR. Just React.

'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/actions/like'

export function LikeButton({ postId, initialCount, initialLiked }: {
  postId: string
  initialCount: number
  initialLiked: boolean
}) {
  const [optimisticState, setOptimistic] = useOptimistic(
    { count: initialCount, liked: initialLiked },
    (current, action: 'toggle') => ({
      count: current.liked ? current.count - 1 : current.count + 1,
      liked: !current.liked,
    })
  )

  const [, startTransition] = useTransition()

  function handleClick() {
    startTransition(async () => {
      setOptimistic('toggle')
      await toggleLike(postId)
    })
  }

  return (
    <button onClick={handleClick}>
      {optimisticState.liked ? '❤️' : '🤍'} {optimisticState.count}
    </button>
  )
}

The optimistic value is temporary — React reverts it automatically if the action throws. If the action succeeds and you call revalidatePath, React reconciles the optimistic state with the real server data. You get instant feedback with zero extra state management overhead.

Look, this is one of those patterns that sounds more complicated than it is. Once you write it once, it clicks. The mental model is: useOptimistic is a parallel state that only exists during a pending transition. After the transition settles, it evaporates.

Mutations From Outside a Form

Not every mutation comes from a form. Delete buttons, toggles, drag-and-drop reorders — these are button clicks or event handlers. You can still call a Server Action from them.

'use client'

import { deletePost } from '@/actions/delete-post'
import { useTransition } from 'react'

export function DeleteButton({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => deletePost(postId))}
      className="text-red-500 disabled:opacity-50"
    >
      {isPending ? 'Deleting...' : 'Delete'}
    </button>
  )
}

Wrap the call in startTransition. This marks the update as non-urgent and gives you isPending for free. Without startTransition, you'd need a manual loading state and you'd also block the UI thread during the async call.

Quick aside: you might wonder if you should just POST to an API route here instead. In practice, for mutations that only happen in one context and don't need to be called from mobile apps or third-party clients, a Server Action is strictly less code. Keep it. The moment you need that endpoint externally, promote it to an API route — the action can call the same business logic.

If you're building UI-heavy apps on top of patterns like these, it pairs well with component libraries that have good interactive states baked in. Check out Empire UI — specifically the form and button components, which are built to handle pending/error/success states without extra wiring.

Error Boundaries, try/catch, and What Actually Propagates

Error handling in Server Actions has two modes and most people conflate them. There's validation errors (expected, return them) and there's thrown exceptions (unexpected, let React handle them).

For validation — don't throw. Return an error object. Throwing from an action causes React to render the nearest error boundary, which is almost never what you want for a typo in an email field.

'use server'

export async function submitContactForm(prevState: unknown, formData: FormData) {
  const email = formData.get('email') as string

  // Validation — return, don't throw
  if (!email.includes('@')) {
    return { error: 'Invalid email address' }
  }

  try {
    await sendEmail({ to: email })
    return { success: true }
  } catch (err) {
    // Infrastructure failure — still return, log the real error
    console.error('Email send failed:', err)
    return { error: 'Failed to send email. Try again in a moment.' }
  }
}

When *should* you throw? When the error is truly unrecoverable — auth failures, DB connection errors, things where you want React to show your error boundary UI. Next.js will catch these and render error.tsx if it's present in that route segment.

In 2026, this split between "handled errors" (return values) and "unhandled errors" (thrown) is well-established React Server Component doctrine. If you're still catching everything and returning { error: 'Something went wrong' }, you're hiding bugs that deserve to surface.

One pattern that scales well is a small safeAction wrapper that standardizes the return type across your whole app. Libraries like next-safe-action (v7+ as of late 2025) do this with Zod validation and typed error returns. Worth evaluating if you have more than a handful of actions.

When to Skip Server Actions and Use an API Route

Server Actions aren't the answer to everything. Here's the short list of when you should use a plain Route Handler (app/api/) instead.

You need the endpoint from outside Next.js. Mobile apps, webhooks, third-party integrations — they can't call a Server Action. They need a URL. That's a Route Handler.

You need streaming responses. Actions return a single resolved value. If you're streaming AI output or chunked data, you need a Response with ReadableStream.

You need custom HTTP response codes. Actions always respond with 200 (or 500 on error). If your API contract requires 201 on create or 404 on not-found, you need a Route Handler.

In practice, most CRUD in a typical Next.js app fits comfortably into Server Actions. The remaining 20% — external-facing endpoints, webhooks, streaming — belongs in Route Handlers. The mistake is using Route Handlers for everything out of habit because that's what you did in Next.js 12. The patterns from the Next.js App Router era have genuinely changed what the default should be.

If you're building design-heavy UIs alongside these patterns — interactive component libraries, style systems, component pickers — browse components to see what Empire UI has available. Some of the form and button components there handle the pending/error/disabled states you'd otherwise write yourself every time.

FAQ

Can I call a Server Action from a Client Component?

Yes, that's the normal pattern. Import the action (from a file with 'use server') into your Client Component and call it from an event handler or pass it to useActionState. The function runs on the server regardless of where it's called from.

Do Server Actions work with file uploads?

They do — FormData handles file inputs and you can read them with formData.get('file') as File. For large uploads, you're better off uploading directly to S3/R2 with a presigned URL and only passing the resulting URL through the action.

What's the difference between useActionState and useFormStatus?

useActionState wraps an action and gives the parent form component access to the last return value and isPending state. useFormStatus reads the pending state of the nearest parent form and is meant for child components like a submit button — it can't access the action's return value.

Should I validate with Zod inside the action or before calling it?

Inside the action, always — you can't trust client-side validation alone since actions are HTTP endpoints. Zod in the action is your real guard. Client-side validation is just UX polish on top of that.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React 19 Form Actions: The New Way to Handle Server MutationsRemix Forms vs Next.js Actions: Two Ways to Handle MutationsServer-Side Streaming in React + Next.js: Suspense and RSCNext.js vs Remix in 2026: Which One Should You Use?