Next.js server actions let a form call a server function directly, with no API route, no fetch boilerplate and no client-side state library. This guide builds a production-ready form step by step: validation with Zod, pending states with useActionState and useFormStatus, error handling, cache revalidation, progressive enhancement and the security checks that are easy to forget.
What Next.js server actions actually are
A Server Action is an async function marked with the 'use server' directive. When you pass it to a form’s action prop or call it from a Client Component, Next.js turns it into an endpoint: the browser sends a POST request with an action ID, Next.js runs the function on the server and streams back the result and any updated UI.
You can declare actions inline inside a Server Component, or in a dedicated file with 'use server' at the top, which makes every exported function an action. I prefer the dedicated file: it keeps server-only code in one place and lets Client Components import the actions.
// app/contact/actions.ts
'use server';
export async function sendMessage(formData: FormData) {
const email = formData.get('email');
const message = formData.get('message');
// ...save to the database or send an email
}
The one thing to internalise before going further: every Server Action is a public HTTP endpoint. Anyone can call it with any payload, regardless of whether the button that triggers it is visible. We will come back to that in the security section.
A basic form action in a Server Component
The simplest form needs no client JavaScript at all. Pass the action to <form action> and React handles submission, serialising the fields into FormData:
// app/contact/page.tsx
import { sendMessage } from './actions';
export default function ContactPage() {
return (
<form action={sendMessage}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit">Send</button>
</form>
);
}
Because the form is server-rendered with a real action, it submits even before JavaScript loads. That is progressive enhancement for free, and it only holds as long as you keep the form a plain <form> with named inputs.
Validating input with Zod
HTML attributes like required are a convenience for users, not a security boundary. Validate on the server every time. Zod’s safeParse gives you typed data or a structured error without throwing:
// app/contact/schema.ts
import { z } from 'zod';
export const contactSchema = z.object({
email: z.string().trim().email('Enter a valid email address'),
message: z
.string()
.trim()
.min(10, 'Message must be at least 10 characters')
.max(5000, 'Message is too long'),
});
export type ContactState = {
ok: boolean;
message?: string;
fieldErrors?: Partial<Record<'email' | 'message', string[]>>;
};
Zod 4 introduced top-level helpers such as z.flattenError() and prefers z.email(); the method-style API shown here still works but some methods are marked deprecated. Use whichever matches the Zod major version in your project.
Returning errors with useActionState
To show validation errors, the action needs to return state, and the form needs to read it. React 19’s useActionState (which replaced the canary-era useFormState) does both. When used with it, the action receives the previous state as its first argument and FormData as its second.
// app/contact/actions.ts
'use server';
import { contactSchema, type ContactState } from './schema';
import { db } from '@/lib/db';
export async function sendMessage(
_prev: ContactState,
formData: FormData
): Promise<ContactState> {
const parsed = contactSchema.safeParse({
email: formData.get('email'),
message: formData.get('message'),
});
if (!parsed.success) {
return {
ok: false,
message: 'Please fix the errors below.',
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
try {
await db.message.create({ data: parsed.data });
} catch (err) {
console.error('sendMessage failed', err);
return { ok: false, message: 'Something went wrong. Please try again.' };
}
return { ok: true, message: 'Thanks, your message has been sent.' };
}
Notice the action returns expected errors as data instead of throwing. Thrown errors go to the nearest error.tsx boundary, which is right for unexpected failures but terrible UX for “your email is invalid”. Also avoid sending raw error messages from the database back to the client; log them and return something generic.
// app/contact/contact-form.tsx
'use client';
import { useActionState } from 'react';
import { sendMessage } from './actions';
import type { ContactState } from './schema';
import { SubmitButton } from './submit-button';
const initialState: ContactState = { ok: false };
export function ContactForm() {
const [state, formAction, isPending] = useActionState(sendMessage, initialState);
return (
<form action={formAction} aria-busy={isPending}>
<input name="email" type="email" aria-describedby="email-error" />
{state.fieldErrors?.email && (
<p id="email-error" role="alert">{state.fieldErrors.email[0]}</p>
)}
<textarea name="message" aria-describedby="message-error" />
{state.fieldErrors?.message && (
<p id="message-error" role="alert">{state.fieldErrors.message[0]}</p>
)}
<SubmitButton />
{state.message && <p aria-live="polite">{state.message}</p>}
</form>
);
}
One React 19 behaviour to know: after a form action completes, React resets uncontrolled form fields. If you want to keep what the user typed after a validation error, return the submitted values in your state and use them as defaultValue.
Pending states with useFormStatus
useFormStatus from react-dom reports whether the parent form is submitting. The catch: it only works in a component rendered inside the <form>, not in the component that renders the form. That is why the button lives in its own file.
// app/contact/submit-button.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send message'}
</button>
);
}
If you already use useActionState, its third return value isPending covers the same need in the form component itself. useFormStatus shines for reusable buttons shared across many forms.
Revalidating the cache after a mutation
A mutation that does not refresh the UI looks broken. After writing, call revalidatePath or revalidateTag inside the action. Next.js then sends the updated server-rendered UI back in the same response, so the page updates without a reload. To navigate after success, call redirect(), but call it outside any try/catch because it works by throwing a special error.
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
// ...validate and auth check first
let postId: string;
try {
const post = await db.post.create({ data: { title: String(formData.get('title')) } });
postId = post.id;
} catch {
return { ok: false, message: 'Could not create post.' };
}
revalidatePath('/posts');
redirect(`/posts/${postId}`);
}
Caching semantics differ between Next.js 14, 15 and 16 (Next.js 16 adds updateTag for read-your-own-writes and changes the revalidateTag signature). I cover those differences in Next.js caching explained.
Securing Next.js server actions
Because actions are reachable by direct POST requests, treat each one like an API route:
- Authenticate inside the action. Hiding the button for logged-out users protects nothing. Check the session at the top of every action.
- Authorise the specific resource. Being logged in is not enough; confirm the user owns the record they are editing. Never trust an ID from a hidden input on its own.
- Validate every field. Use a schema, and only pass whitelisted fields to your ORM to avoid mass assignment.
- Do not rely on middleware alone. Middleware (renamed to proxy in Next.js 16) runs before routing and can be misconfigured or bypassed; do the check where the data is touched.
- Rate limit sensitive actions such as login, signup and contact forms.
'use server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { revalidateTag } from 'next/cache';
export async function deleteInvoice(invoiceId: string) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const invoice = await db.invoice.findUnique({ where: { id: invoiceId } });
if (!invoice || invoice.ownerId !== session.user.id) {
throw new Error('Forbidden');
}
await db.invoice.delete({ where: { id: invoiceId } });
revalidateTag('invoices', 'max'); // Next.js 15: revalidateTag('invoices')
}
Next.js adds some protections for you: actions only accept POST, it compares the Origin and Host headers to block cross-site calls (configurable with serverActions.allowedOrigins), action IDs are not guessable, and closed-over variables are encrypted. Those are defences against CSRF, not a substitute for authorisation. For the wider picture, the principles in preventing SQL injection, XSS and CSRF apply equally to JavaScript backends.
Progressive enhancement and when to use client calls
Forms rendered with action={serverAction} or action={formAction} from useActionState submit as normal HTML forms before hydration. To keep that working, avoid onSubmit with preventDefault, and make sure every value lives in a named input rather than only in React state. If you need optimistic UI, useOptimistic layers on top without breaking the no-JS path.
You can also call actions from event handlers, for example a “like” button with startTransition(() => likePost(id)). That is fine for small interactions, but it requires JavaScript. Server actions are also not ideal for reading data; fetch in Server Components instead, because actions run sequentially and are designed for mutations.
Key takeaways
- Next.js server actions are public
POSTendpoints: authenticate and authorise inside every action. - Validate with Zod’s
safeParseand return field errors as state rather than throwing. - Use
useActionStatefor state and pending, anduseFormStatusin a child component for reusable buttons. - Revalidate after writes and call
redirect()outsidetry/catch. - Plain forms with named inputs keep working before JavaScript loads.
Frequently asked questions
Are Next.js server actions secure by default?
Partly. Next.js restricts them to POST, checks the Origin header and encrypts closures, but any action can still be called directly by anyone, so you must check authentication, authorisation and input inside each action.
Why does useFormStatus always return pending as false?
useFormStatus only reads the status of a parent form, so it must be called in a component rendered inside the <form>. Calling it in the same component that renders the form always returns false.
What replaced useFormState in React 19?
useActionState, imported from react, replaced useFormState from react-dom. It also returns an isPending flag as its third value.
Why does redirect() not work inside my try/catch?
redirect() throws a special error that Next.js catches to perform the navigation, so a surrounding catch swallows it. Call redirect() after the try/catch block.
Building forms, dashboards or a full product on the Next.js App Router? Contact me if you want an experienced developer to build it or review your server actions for security.