EmpireUI
Get Pro
← Blog8 min read#react#next-js#server-actions

React Server Actions: Complete Guide for Next.js App Router

React Server Actions in Next.js App Router let you run server-side logic directly from components. Here's how to use them without the usual pitfalls.

Code editor showing React and Next.js server actions code on a dark background

What Are React Server Actions and Why They Matter

Honestly, Server Actions are one of the most quietly disruptive features shipped in Next.js 13.4 — and most developers are still not using them to their full potential. They let you define async functions that run exclusively on the server, call them directly from client components, and handle form submissions without a separate API route. No extra endpoint. No fetch boilerplate.

Before Server Actions landed, a typical Next.js form would POST to /api/submit, you'd parse the body, hit the database, return a JSON response, then handle it client-side. That's four round-trips of glue code for what should be a single operation. Server Actions collapse that into one function call.

They're stable as of Next.js 14 (the experimental.serverActions flag is gone). You can use them today in any App Router project without touching a config file. If you're still on Pages Router, this guide isn't for you yet — but it's a good reason to consider migrating.

How to Define a Server Action in Next.js App Router

The 'use server' directive is how Next.js knows a function should run on the server. You can put it at the top of a dedicated file — which makes the entire file's exports Server Actions — or inline it inside a single async function.

Here's a minimal example: a form that saves a contact message to a database without any API route.

``tsx // app/actions/contact.ts 'use server' import { db } from '@/lib/db' export async function submitContact(formData: FormData) { const name = formData.get('name') as string const email = formData.get('email') as string const message = formData.get('message') as string if (!name || !email || !message) { return { error: 'All fields are required.' } } await db.contact.create({ data: { name, email, message }, }) return { success: true } } ` The function receives a FormData object automatically when wired to a <form> action prop. No JSON parsing. No req.body`. The database call happens server-side — the client never sees your DB credentials or query logic.

Wiring Server Actions to Forms and Client Components

There are two main patterns. The first is the native HTML action prop on a <form> — works without JavaScript enabled, which is genuinely useful for accessibility and resilience. The second is calling the action directly as an async function, which gives you more control over loading states and error handling.

``tsx // app/contact/page.tsx import { submitContact } from '@/actions/contact' export default function ContactPage() { return ( <form action={submitContact} className="flex flex-col gap-4"> <input name="name" type="text" placeholder="Your name" className="border rounded-lg px-4 py-2 text-sm" required /> <input name="email" type="email" placeholder="Email address" className="border rounded-lg px-4 py-2 text-sm" required /> <textarea name="message" rows={4} placeholder="Your message" className="border rounded-lg px-4 py-2 text-sm resize-none" required /> <button type="submit" className="bg-indigo-600 text-white rounded-lg px-6 py-2 text-sm font-medium" > Send message </button> </form> ) } ` This runs submitContact on the server when the form is submitted. No 'use client'` needed. But you don't get loading state feedback this way, which matters for UX.

For richer interactivity — loading spinners, optimistic UI, inline error messages — you'll want useActionState (formerly useFormState in React 18). This hook was stabilised in React 19 and ships with Next.js 15.

useActionState and useFormStatus for Pending States

The useActionState hook wraps your Server Action and gives you back the latest return value plus a wrapped action function you pass to the form. Pair it with useFormStatus inside the submit button to get the pending boolean — no manual useState needed.

``tsx 'use client' import { useActionState } from 'react' import { useFormStatus } from 'react-dom' import { submitContact } from '@/actions/contact' function SubmitButton() { const { pending } = useFormStatus() return ( <button type="submit" disabled={pending} className="bg-indigo-600 text-white rounded-lg px-6 py-2 text-sm font-medium disabled:opacity-50 transition-opacity duration-150" > {pending ? 'Sending…' : 'Send message'} </button> ) } export default function ContactForm() { const [state, action] = useActionState(submitContact, null) return ( <form action={action} className="flex flex-col gap-4"> {state?.error && ( <p className="text-red-500 text-sm">{state.error}</p> )} {state?.success && ( <p className="text-green-600 text-sm">Message sent. We'll be in touch.</p> )} <input name="name" type="text" placeholder="Your name" required className="border rounded-lg px-4 py-2 text-sm" /> <input name="email" type="email" placeholder="Email address" required className="border rounded-lg px-4 py-2 text-sm" /> <textarea name="message" rows={4} placeholder="Your message" required className="border rounded-lg px-4 py-2 text-sm resize-none" /> <SubmitButton /> </form> ) } ` Note that useFormStatus must be called inside a component that's a *child* of the form — that's why SubmitButton` is a separate component. If you put it in the same component as the form, it won't detect the pending state. This trips up a lot of people the first time.

If you need toast notifications on success or error rather than inline messages, just call your toast library inside the client component once the state changes — use a useEffect watching state.

Revalidation, Redirects, and Cache Control After Mutations

Server Actions integrate directly with Next.js's cache system. After a mutation — create, update, delete — you'll almost always want to refresh the affected data. The two main tools are revalidatePath and revalidateTag, both from next/cache.

``ts 'use server' import { revalidatePath, revalidateTag } from 'next/cache' import { redirect } from 'next/navigation' import { db } from '@/lib/db' export async function createPost(formData: FormData) { const title = formData.get('title') as string const content = formData.get('content') as string const post = await db.post.create({ data: { title, content } }) // Invalidate the posts list page revalidatePath('/blog') // Or use a cache tag if you tagged your fetch: // revalidateTag('posts') // Redirect to the new post redirect(/blog/${post.slug}) } ` revalidatePath busts the full-route cache for that URL. revalidateTag is more surgical — it only invalidates fetch calls that were tagged with that string. For most CRUD apps, revalidatePath` is fine. For high-traffic pages with many independent data sources, tags are worth the extra setup.

One thing worth knowing: redirect() throws internally, so don't wrap it in a try/catch. If you need to handle errors before redirecting, do your error checks first, then call redirect at the end when you know everything succeeded. This catches a lot of developers off guard.

Server Action Security: Validation and Authorization

Here's the thing: Server Actions are public endpoints. Anything you export from a 'use server' file can be called from the client. Next.js generates an encrypted action ID for each one, so the function name isn't exposed, but the endpoint is real and reachable. You cannot skip validation.

Always validate input server-side. Zod is the obvious choice — it's type-safe, the error messages are readable, and it works exactly the same in Server Actions as anywhere else. Don't trust required on an HTML input. Don't trust the shape of FormData. Validate everything.

``ts 'use server' import { z } from 'zod' import { auth } from '@/lib/auth' const schema = z.object({ title: z.string().min(3).max(120), content: z.string().min(10), published: z.coerce.boolean().optional(), }) export async function updatePost(id: string, formData: FormData) { // 1. Check authentication first const session = await auth() if (!session?.user) { return { error: 'Not authenticated.' } } // 2. Validate input const parsed = schema.safeParse({ title: formData.get('title'), content: formData.get('content'), published: formData.get('published'), }) if (!parsed.success) { return { error: parsed.error.flatten().fieldErrors } } // 3. Check authorization (does this user own the post?) const existing = await db.post.findUnique({ where: { id } }) if (existing?.authorId !== session.user.id) { return { error: 'Not authorized.' } } await db.post.update({ where: { id }, data: parsed.data }) return { success: true } } `` Authentication → validation → authorization. In that order. Swapping them around wastes DB calls or leaks information. If you're building forms with React Hook Form on the client side, you'll still want this server-side validation as a second layer — client validation is UX, server validation is security.

Optimistic Updates with useOptimistic

Optimistic UI is one of those things that makes an app *feel* fast even on slow connections. React 19 ships useOptimistic for exactly this. You give it the current state and an update function, and it returns a temporary "optimistic" state that gets shown immediately while the server action is in flight.

A classic use case: a like button on a post. You want the count to increment immediately when clicked, not after the server confirms it. If the action fails, React rolls back the optimistic state automatically.

``tsx 'use client' import { useOptimistic, useTransition } from 'react' import { toggleLike } from '@/actions/posts' type Props = { postId: string; initialLikes: number; liked: boolean } export function LikeButton({ postId, initialLikes, liked }: Props) { const [isPending, startTransition] = useTransition() const [optimisticState, addOptimistic] = useOptimistic( { likes: initialLikes, liked }, (current, update: { liked: boolean }) => ({ likes: update.liked ? current.likes + 1 : current.likes - 1, liked: update.liked, }) ) function handleClick() { startTransition(async () => { const newLiked = !optimisticState.liked addOptimistic({ liked: newLiked }) await toggleLike(postId) }) } return ( <button onClick={handleClick} disabled={isPending} className={flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-colors duration-150 ${ optimisticState.liked ? 'bg-rose-100 text-rose-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }} > <span>{optimisticState.liked ? '♥' : '♡'}</span> <span>{optimisticState.likes}</span> </button> ) } ` Why wrap it in startTransition? Because useOptimistic only works inside a transition. The transition tells React this update is non-urgent and can be interrupted. Without it, you get a runtime warning in development and potentially buggy behavior in production. Always wrap Server Action calls that use useOptimistic in startTransition`.

Common Pitfalls and Patterns Worth Knowing

Can you use Server Actions with third-party state managers like Zustand or Jotai? Yes, but it's awkward. You call the action, await the result, then manually update your store. There's no automatic sync. For most cases, leaning on useActionState plus revalidatePath is cleaner and requires less client-side state management overall.

Passing non-serializable values to Server Actions will throw. Functions, class instances, React elements — none of these can cross the server/client boundary. You're limited to JSON-serializable values plus FormData, File, Blob, ReadableStream, and a few others. If you hit a serialization error, check what you're passing as arguments.

Error handling is worth thinking about carefully. If a Server Action throws an uncaught error, Next.js will show the nearest error.tsx boundary in production. That's usually not what you want for form validation errors. Return error objects explicitly instead of throwing — the { error: string } pattern from earlier in this guide is the idiomatic approach. For unexpected server errors (DB connection failures, etc.), let them throw and let the error boundary handle it. For overall app performance, keep your Server Actions focused — don't do five database queries in one action when two would do. And if your UI involves layered visual complexity like glassmorphism effects over dynamic content, Server Actions keep your data fetching logic clean and server-side while your visual layer stays purely on the client.

FAQ

Can I use Server Actions with the Next.js Pages Router?

No. Server Actions are an App Router feature only. They rely on React Server Components infrastructure that doesn't exist in the Pages Router. If you're on Pages Router, you need a regular API route (/pages/api/...) for server-side mutations.

What's the difference between useFormState and useActionState?

They do the same thing. useFormState was the original name in React 18 / Next.js 14. It was renamed to useActionState in React 19 and is now available directly from react instead of react-dom. In Next.js 15+, use useActionState from react. In older projects on Next.js 14, use useFormState from react-dom.

Are Server Actions secure? Can users call them directly?

Server Actions are HTTP endpoints — Next.js sends a POST with an encrypted action ID in the request. The function name is not exposed, but the endpoint is reachable. Always validate input with Zod or similar, check authentication with your auth library, and verify the user has permission to perform the specific mutation. Never assume a Server Action is only reachable from your UI.

How do I pass additional arguments to a Server Action beyond FormData?

Use .bind() to pre-fill arguments. For example: const updateWithId = updatePost.bind(null, post.id) creates a new function that will call updatePost(post.id, formData) when submitted. You can then pass updateWithId as the form's action prop. This is the official pattern for passing IDs or other fixed values.

Can Server Actions upload files?

Yes. File inputs are included in FormData as File objects. You can read formData.get('avatar') as File, then upload to S3 or Cloudflare R2 directly from the Server Action. File size limits apply based on your hosting provider — Vercel has a 4.5MB body limit by default, configurable per route.

Do Server Actions work without JavaScript on the client?

Yes, when used with the native <form action={serverAction}> pattern. The form will submit via a regular HTML POST, the Server Action runs, and Next.js returns a full page response. This is one of the genuine advantages over custom API routes — progressive enhancement works out of the box.

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 MutationsScroll Progress Indicator: Reading Bar in Next.js App RouterNext.js App Router vs Pages Router: Which to Use in 2026