Next.js Server Actions: Complete Guide with Examples
By Pawan Singh · Jun 2026
What a Server Action Actually Is
A Server Action is a function that runs exclusively on the server, callable directly from a component (server or client) without you manually building an API route and a fetch call to reach it. Under the hood, Next.js still makes an HTTP request when a client component invokes one — the difference is you write and call it like a normal async function, and the framework generates and wires up the network plumbing for you.
Defining a Server Action
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title');
const body = formData.get('body');
await db.posts.create({ data: { title, body } });
revalidatePath('/blog');
}
The 'use server' directive marks every exported function in the file (or a single function, if placed inside its body) as callable from the client, while its actual execution always happens server-side — the function's code itself never ships to the browser bundle.
Using It Directly in a Form
import { createPost } from './actions';
export default function NewPostForm() {
return (
<form action={createPost}>
<input name="title" />
<textarea name="body" />
<button type="submit">Create</button>
</form>
);
}
This works even with JavaScript disabled in the browser — the <form>'s native submission behavior handles it, since action accepting a function is a real HTML forms feature Next.js builds on, not a client-side-JS-only trick. This is a genuine, meaningful capability the Pages Router's API-route-plus-fetch pattern never had.
Revalidating Data After a Mutation
revalidatePath('/blog');
// or, for content identified by a tag rather than a specific path
revalidateTag('posts');
Without this, cached pages/data continue serving pre-mutation content after a successful write — revalidatePath/revalidateTag are what tell Next.js's cache "this data just changed, the next request for it should regenerate rather than serve the stale cached version." Forgetting this call is the most common reason "the mutation worked (the database updated) but the UI still shows old data" happens.
Handling Pending State and Errors With useFormStatus / useActionState
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}
useFormStatus must be called from a component rendered inside the <form>, not the form's own parent — it reads status from the nearest enclosing form, not from anywhere in the tree. For returning validation errors or a success message back to the UI, useActionState pairs a Server Action with client-visible state:
'use client';
import { useActionState } from 'react';
import { createPost } from './actions';
function NewPostForm() {
const [state, formAction] = useActionState(createPost, { error: null });
return (
<form action={formAction}>
<input name="title" />
{state.error && <p>{state.error}</p>}
<button type="submit">Create</button>
</form>
);
}
The action's return value becomes the new state, letting a Server Action communicate validation failures back to the form without a separate client-side error-handling round trip.
Calling a Server Action Outside a Form
Server Actions aren't limited to form submissions — call one directly from an event handler in a Client Component, e.g. from a button's onClick, for actions that aren't naturally a form submit (deleting an item, toggling a like):
'use client';
import { deletePost } from './actions';
function DeleteButton({ id }) {
return <button onClick={() => deletePost(id)}>Delete</button>;
}
Security: Server Actions Are Public Endpoints
A Server Action is reachable by anyone who can construct the right request, not just by the specific UI that happens to call it — the same as any other server endpoint. Always re-check authentication and authorization inside the action itself, never assume "this button is only shown to admins in the UI" is a sufficient access control on its own; a Server Action must independently verify who's calling it.
Common Issues
- UI doesn't update after a successful mutation — missing
revalidatePath/revalidateTagcall inside the action. - "useFormStatus must be used within a form" or it never shows pending state — the component calling it isn't actually rendered as a descendant of the
<form>whose status it's trying to read. - Unauthorized users can trigger a "hidden" action — no server-side authorization check inside the action itself; UI-level hiding is not access control.
- Action silently does nothing — missing the
'use server'directive at the top of the file or function.
Building forms and mutations in Next.js the right way? Get in touch — I work as a freelance Next.js developer.