Next.js Server Actions Deep Dive
Server Actions explained: what they are under the hood, how to use them for forms and mutations, and the security and UX patterns that matter.
What you'll learn
- ✓What Server Actions really are under the hood
- ✓How to wire them to forms with progressive enhancement
- ✓How to validate and authorize on the server
- ✓Optimistic UI patterns
- ✓Common security gotchas
Prerequisites
- •Next.js App Router basics
Server Actions are async functions you can call from a client component as if they were local, but they execute on the server. The framework handles the RPC. They are not a magic wand: they are a thin layer with rules about validation, authorization, and revalidation. Treat them like API endpoints with a nicer syntax, because that is what they are.
What they are
A function with 'use server' is registered as a server endpoint. When you import it into a client component and call it, the framework serializes the arguments, makes a POST to the server, runs the function, and gives you back the result. Because the function is a real endpoint, the same security rules apply as for any API.
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const Input = z.object({ title: z.string().min(1).max(120) });
export async function createPost(formData) {
const parsed = Input.parse({ title: formData.get('title') });
await db.posts.insert(parsed);
revalidatePath('/posts');
}
Mental model
Client component
|
| onSubmit / startTransition
v
React serializes args -> POST /__next/action
|
v
Server runs the function -> revalidate / return value
|
v
Client gets result + new RSC payload The “magic” is that React knows how to encode arguments and decode return values, including across React Server Component re-renders. Everything else is normal HTTP.
Hands-on: a form with progressive enhancement
A form bound to a Server Action works without JavaScript. The browser POSTs the form, the server runs the action, and the page is re-rendered. With JS, the same action runs without a full page reload.
// app/posts/new/page.tsx
import { createPost } from '../actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">Create</button>
</form>
);
}
For loading states and errors, use the useActionState and useFormStatus hooks.
'use client';
import { useActionState } from 'react';
import { createPost } from '../actions';
export default function NewPostForm() {
const [state, action, pending] = useActionState(createPost, { error: null });
return (
<form action={action}>
<input name="title" required />
{state.error && <p>{state.error}</p>}
<button disabled={pending}>Create</button>
</form>
);
}
The action now returns { error } on failure, and pending flips while the request is in flight.
Hands-on: optimistic updates
useOptimistic lets you show the change immediately while the action runs.
'use client';
import { useOptimistic } from 'react';
import { togglePost } from '../actions';
export function PostRow({ post }) {
const [optimistic, applyOptimistic] = useOptimistic(post, (s, next) => ({ ...s, ...next }));
return (
<form action={async (fd) => {
applyOptimistic({ done: !optimistic.done });
await togglePost(fd);
}}>
<input type="hidden" name="id" value={post.id} />
<button>{optimistic.done ? 'Undo' : 'Done'}</button>
</form>
);
}
If the action throws, React reverts the optimistic state automatically.
Security: actions are public endpoints
Anyone who can POST to your site can invoke an action. The fact that you only call it from a logged-in component is irrelevant; the endpoint is public. Always re-check auth and authorization inside the action.
'use server';
import { getSession } from '@/lib/auth';
export async function deletePost(id) {
const session = await getSession();
if (!session) throw new Error('unauthorized');
const post = await db.posts.find(id);
if (post.userId !== session.userId) throw new Error('forbidden');
await db.posts.delete(id);
}
Validate inputs with Zod or similar. The client can send anything.
Common pitfalls
- Trusting the caller. Actions are routes. Re-authenticate and re-authorize every time.
- Returning non-serializable values. Dates, Maps, and Sets are fine; class instances, functions, and Buffers are not.
- Forgetting to revalidate. Mutations that do not call
revalidatePathorrevalidateTagleave stale cached pages. - Putting actions in client files.
'use server'at the top of a client component is not what you want; the directive declares a server function and the file must not also be a client component. - Catching errors silently. Thrown errors are how React knows to roll back optimistic state.
- Sending large payloads. Actions go through the same body-size limits as other endpoints; uploads belong in route handlers with streaming.
Practical tips
- Co-locate actions with the route that uses them.
app/posts/actions.tsnext toapp/posts/page.tsx. - Always validate with a schema and parse before you use
formData. - Return discriminated unions (
{ ok: true, data } | { ok: false, error }) instead of throwing for expected business errors; reserve throws for unexpected failures. - Use
useFormStatusinside the submit button for accurate disabled state. - Rate-limit actions like any API. Per-IP, per-user, per-action.
Wrap-up
Server Actions remove a lot of glue between forms and mutations: no fetch, no manual JSON, no route handler boilerplate, and progressive enhancement for free. The price is remembering that they are real public endpoints. Validate, authorize, revalidate, and you get the ergonomics without the foot-guns.
Related articles
- Next.js Next.js Server Actions: A Form Actions Deep Dive
How Next.js Server Actions replace API routes for form submissions — progressive enhancement, useFormState and useFormStatus, validation, revalidation, and avoiding the most common mistakes.
- Next.js Next.js Server Actions Explained
Use Next.js server actions to handle form submissions and mutations without API routes. Covers use server, revalidation, and security considerations.
- Next.js What Is Next.js? Full-Stack React Explained
A clear introduction to Next.js — what it is, why it exists on top of React, what the App Router and Server Components actually do, and where it fits compared to plain React, Astro, and Remix.
- Web Next.js App Router vs Pages Router
Compare the Next.js App Router and Pages Router: routing, data fetching, layouts, server components, and how to decide for new and existing projects.