
Next.js Creating forms with Server Actions
For most of the last decade, building a form in a React app meant the same ritual: wire up useState for every field, write an onSubmit handler that calls preventDefault(), serialize the fields into a JSON body, fire off a fetch call to some API route, wait for the response, and manually update the UI to reflect success or failure. It works, but it's a lot of boilerplate for something as old as the web itself — submitting a form.
Next.js, through React's Server Functions, gives you a different path. You can hand the browser's native <form> element a function that runs entirely on the server, skip the manual fetch plumbing, and still get validation, pending states, optimistic UI, and progressive enhancement along the way. This guide walks through how that works in practice, what the official docs gloss over, and where people commonly trip themselves up.
One clarification before we start: this article is specifically about the forms use case of Server Actions — extracting FormData, validating it, and mutating data from a submit. If you want to understand Server Actions as a general mechanism (the single-roundtrip response model, sequential dispatch, deployment behavior, and security architecture that don't only apply to forms), that's a separate, deeper topic covered in "Server Actions and Mutations." Think of this one as the practical, form-shaped subset of that bigger picture.
The Mental Model: A Function Is the Endpoint
The core idea is refreshingly simple. React extends the HTML <form> element so that its action attribute can point at a function instead of a URL string. That function is marked with the 'use server' directive, which tells the Next.js build to treat it as a Server Function — code that only ever executes on the server, no matter where it's called from.
// app/invoices/page.tsx
import { auth } from "@/lib/auth";
export default function Page() {
async function createInvoice(formData: FormData) {
"use server";
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
const rawFormData = {
customerId: formData.get("customerId"),
amount: formData.get("amount"),
status: formData.get("status"),
};
// mutate data
// revalidate the cache
}
return <form action={createInvoice}>{/* fields */}</form>;
}
When the browser submits this form, React intercepts the submission, serializes the form's fields into a FormData object, and calls createInvoice on the server with that object as its argument. No fetch, no JSON serialization step you have to write yourself, no API route to define separately. The function is the endpoint.
This is worth sitting with for a second, because it inverts a habit most React developers have built over years: you're no longer thinking "what URL does this data go to," you're thinking "what function processes this data." The framework handles getting the data there.
Why the security warning at the top of the docs matters more than it looks
The official docs open this guide with a boxed warning: always verify authentication and authorization inside the Server Action itself, even if the form only ever appears on a page that's already behind a login wall. It's easy to read that and nod along without absorbing why it's not optional advice.
Here's the reality: a Server Action, once your app ships, becomes a callable HTTP endpoint with its own URL under the hood (Next.js creates an internal POST route for it). Nothing stops someone from finding that endpoint and calling it directly with curl, bypassing your page, your layout, and any auth check that only lives in a parent Server Component. If the page checks session but createInvoice doesn't, you've built a locked front door with an unlocked window right next to it. The auth() check inside the function in the example above isn't decoration — it's the actual security boundary.
Reading the Data: FormData, Not JSON
Because the browser is doing native form submission under the hood, what your Server Action receives is a FormData object, not a parsed JSON payload. If you've spent years working exclusively with JSON APIs, this is the part that feels the most foreign.
"use server";
export async function createInvoice(formData: FormData) {
const customerId = formData.get("customerId");
const amount = formData.get("amount");
const status = formData.get("status");
// ...
}
For a form with more than a handful of fields, calling .get() repeatedly gets tedious fast. Object.fromEntries() collapses that into one line:
const rawFormData = Object.fromEntries(formData);
One thing the docs mention only in passing but that will bite you if you don't know it: that object will include extra keys prefixed with $ACTION_ — internal bookkeeping React attaches to the submission. If you're about to hand rawFormData straight to a database insert or a schema validator with .strict() mode, those extra keys will either pollute your data or throw a validation error you didn't expect. Strip them, or validate only the specific keys you care about rather than trusting the whole object shape.
const { customerId, amount, status } = Object.fromEntries(formData) as {
customerId: string;
amount: string;
status: string;
};
Also worth internalizing: every value that comes out of FormData.get() is either a string, a File, or null. There is no automatic type coercion. A number input still gives you the string "42", not the number 42. Whatever validation library you reach for needs to account for that, or you'll spend an afternoon debugging why your Zod schema keeps rejecting perfectly good numeric input.
Passing Extra Arguments the Form Doesn't Have
Sometimes a Server Action needs information that isn't a form field — a userId from a route param, a tenant ID from context, an ID for the record being edited. The docs recommend JavaScript's native bind() method for this, and it's worth understanding why that's the recommended approach rather than just an approach.
"use client";
import { updateUser } from "./actions";
export function UserProfile({ userId }: { userId: string }) {
const updateUserWithId = updateUser.bind(null, userId);
return (
<form action={updateUserWithId}>
<input type="text" name="name" />
<button type="submit">Update User Name</button>
</form>
);
}
// app/actions.ts
"use server";
export async function updateUser(userId: string, formData: FormData) {
// userId is bound; formData is the second argument
}
The alternative — stuffing the value into a hidden input (<input type="hidden" name="userId" value={userId} />) — works too, but it puts that value directly into the rendered HTML, in plain text, where anyone with browser DevTools can read or tamper with it before submitting. bind() closes over the value in the function itself; it's not sitting in the DOM waiting to be edited. If userId is something a malicious user shouldn't be able to change (which record they're updating, for instance), bind() is the safer default, not just the more elegant one.
The other underrated property of bind(): it works identically whether the form is inside a Server Component or a Client Component, and it doesn't break progressive enhancement (more on that below). A hidden input is a workaround; bind() is the intended mechanism.
Validating Input — Client-Side, Server-Side, or Both
You get two layers of validation available to you, and in a production app you generally want both, for different reasons.
Client-side validation via native HTML attributes (required, type="email", minLength) gives instant feedback and cuts down on unnecessary round-trips for obviously invalid input. It costs nothing to add and improves the experience.
Server-side validation is the one that actually matters for correctness and security, because client-side validation is trivially bypassed — anyone can submit a raw POST request to your Server Action's endpoint with curl, ignoring every HTML attribute you set. A schema library like Zod (or Valibot, if you prefer something with a smaller footprint) gives you a declarative way to validate the extracted fields on the server, where it can't be skipped.
// app/actions.ts
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string({
invalid_type_error: "Invalid Email",
}),
});
export default async function createUser(formData: FormData) {
const validatedFields = schema.safeParse({
email: formData.get("email"),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
// proceed with validated, typed data
}
safeParse rather than parse is the right call here specifically because you're inside a Server Action: you want to return a structured error object the UI can render, not throw an unhandled exception that produces a generic error boundary with no useful feedback for the person filling out the form.
Showing Validation Errors With useActionState
Returning { errors: ... } from the action is only half the story — you need a way to get that value back into the component that rendered the form. This is where useActionState (from React, re-exported for you to import directly) comes in. It's the piece that finally replaces the old "manage form state with useState and a manual submit handler" pattern.
Using useActionState changes your Server Action's signature: it now receives the previous state as its first argument, with formData shifted to second.
// app/actions.ts
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string({ invalid_type_error: "Invalid Email" }),
});
export async function createUser(initialState: any, formData: FormData) {
const validatedFields = schema.safeParse({
email: formData.get("email"),
});
if (!validatedFields.success) {
return { message: "Please enter a valid email." };
}
// mutate data
return { message: "" };
}
// app/ui/signup.tsx
"use client";
import { useActionState } from "react";
import { createUser } from "@/app/actions";
const initialState = { message: "" };
export function Signup() {
const [state, formAction, pending] = useActionState(createUser, initialState);
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input type="text" id="email" name="email" required />
<p aria-live="polite">{state?.message}</p>
<button disabled={pending}>Sign up</button>
</form>
);
}
Two details in that example are easy to skim past but matter a lot in practice:
aria-live="polite" on the error message. Without it, a screen reader user who submits the form and gets a validation error has no idea anything happened — the DOM changed, but nothing announced it. This one attribute is the difference between an accessible form and one that silently fails for a chunk of your users. It costs one attribute; there's no excuse to skip it.
The component using the action must be a Client Component. useActionState is a React hook, and hooks only run in Client Components. This trips people up because the action itself stays a Server Function — you're not converting your mutation logic to client-side code, you're just converting the component that renders the form to a Client Component so it can hold onto the hook's returned state. The action, the validation, and the mutation still run exclusively on the server.
Pending States: Two Ways to Get There
useActionState's third return value, pending, is a boolean you can use directly to disable the submit button or show a spinner while the action runs:
const [state, formAction, pending] = useActionState(createUser, initialState);
return (
<form action={formAction}>
<button disabled={pending}>Sign up</button>
</form>
);
If you'd rather not lift the whole form into useActionState — say, you have a design system with a generic <SubmitButton> component you want to reuse across many different forms without threading state through each one — useFormStatus gives you the same pending flag from inside a child of the form, without the parent needing to manage anything:
// app/ui/button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending} type="submit">
Sign Up
</button>
);
}
// app/ui/signup.tsx
import { SubmitButton } from "./button";
import { createUser } from "@/app/actions";
export function Signup() {
return (
<form action={createUser}>
<SubmitButton />
</form>
);
}
The catch with useFormStatus: it only works when the component calling it is nested inside the <form> it's reporting on — it reads status from the nearest form ancestor via context. Call it in the same component that renders the <form> tag and it won't see anything; React specifically requires the separation shown above. This is one of the more common "why is pending always false" bugs people hit, and the fix is simply: pull the button into its own component.
One more thing worth flagging that isn't obvious from either hook in isolation: if the experimental useOffline configuration is enabled in your app, a Server Action that gets interrupted by a dropped connection doesn't just fail — it stays pending and completes automatically once the network returns. That changes what "pending" even means for a user on a flaky connection: it's not necessarily stuck, it might just be patiently waiting to retry. Worth knowing if you're building for users on mobile networks or spotty Wi-Fi.
Optimistic Updates: Don't Make Users Wait to See Their Own Message
For anything chat-like — comments, messages, a live feed — waiting for a server round-trip before the UI reflects what the user just did feels sluggish, even on a fast connection. useOptimistic lets you render the expected result immediately, then reconcile with reality once the server responds.
"use client";
import { useOptimistic } from "react";
import { send } from "./actions";
type Message = { message: string };
export function Thread({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic<
Message[],
string
>(messages, (state, newMessage) => [...state, { message: newMessage }]);
const formAction = async (formData: FormData) => {
const message = formData.get("message") as string;
addOptimisticMessage(message);
await send(message);
};
return (
<div>
{optimisticMessages.map((m, i) => (
<div key={i}>{m.message}</div>
))}
<form action={formAction}>
<input type="text" name="message" />
<button type="submit">Send</button>
</form>
</div>
);
}
Notice that formAction here is a plain client-side async function, not the Server Action itself — it wraps the optimistic update and the call to the actual send Server Function. This pattern (a thin client wrapper around the real action) is common enough that it's worth recognizing as its own idiom: use it any time you need client-only bookkeeping (an optimistic update, an analytics event, a client-side redirect) alongside the server mutation.
The part the docs don't spell out: if send() fails, optimisticMessages doesn't automatically roll back the entry you added. React discards the optimistic state and re-renders from the real messages prop once it updates — but if your mutation didn't actually revalidate the data backing that prop, the optimistic message can appear to just "stick" successfully even on failure, or vanish with no indication of an error, depending on how your surrounding code is wired. If a failed send needs to visibly surface an error to the user (not just silently disappear), you need to handle that explicitly — catch the rejection from send() and set some separate error state, since useOptimistic itself doesn't give you a failure callback.
Nested Form Elements: Multiple Actions, One Form
Not every form has exactly one submit button doing exactly one thing. A blog post editor might need both a "Save Draft" button and a "Publish" button, each triggering a different Server Action from the same set of fields. React supports this through the formAction prop on individual buttons, which overrides the form's own action for that specific button:
<form action={publishPost}>
{/* shared fields */}
<button type="submit">Publish</button>
<button type="submit" formAction={saveDraft}>
Save Draft
</button>
</form>
This is a small detail in the docs (a single sentence, easy to miss) but it solves a real, common problem cleanly — no need for a single mega-action that branches on some hidden "intent" field, which is the workaround people reach for when they don't know formAction exists on nested elements.
Programmatic Submission
Sometimes you want to trigger a submission without a literal click on a submit button — a keyboard shortcut like ⌘+Enter in a comment box, for instance. The native requestSubmit() method on the form element handles this without any extra plumbing:
"use client";
export function Entry() {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
(e.ctrlKey || e.metaKey) &&
(e.key === "Enter" || e.key === "NumpadEnter")
) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
};
return (
<div>
<textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
</div>
);
}
requestSubmit() triggers the nearest ancestor <form>'s real submission — including native validation (required, minLength, etc.) — which is the key advantage over manually calling the Server Action yourself from the event handler. If you called the action directly instead, you'd bypass every bit of built-in HTML validation and would need to reimplement it yourself.
Progressive Enhancement Is the Part People Underrate
Here's a property of this whole system that's easy to take for granted until you understand what it's replacing: forms built this way work before JavaScript has finished loading, and they keep working if JavaScript fails to load at all. Because the underlying mechanism is a real HTML <form> element with a real submission behavior, a slow 3G connection, a JS bundle that errors out, or a user with JavaScript disabled entirely still gets a functioning form — it just falls back to a full page navigation instead of a smooth client-side transition.
This is a meaningfully different reliability story than the "form + onSubmit + fetch" pattern most React developers have been writing for years, where a form that hasn't hydrated yet is a form that does nothing at all when you click submit. You don't have to do anything special to get this benefit — it's a consequence of building on the real <form> element instead of hijacking click events, so it's worth appreciating rather than assuming it's incidental.
Common Mistakes
Forgetting to revalidate after a mutation. A Server Action can update your database perfectly and still leave the UI showing stale data, because Next.js doesn't automatically know which cached pages depend on what you just changed. Call revalidatePath or revalidateTag explicitly wherever your mutation should be reflected.
Trusting Object.fromEntries(formData) output without stripping the $ACTION_-prefixed keys, as covered above — validate the specific fields you expect rather than the whole object.
Putting useActionState in a Server Component file and being confused why it errors. The form-rendering component needs 'use client' at the top; the action it calls stays server-only regardless.
Skipping the auth check inside the action because "the page is already protected." As covered up top, the action is independently callable — protect it independently.
Calling useFormStatus in the same component as the <form> tag. It only sees pending state from a form that is an ancestor, not the same component instance.
Key Takeaways
| Need | Tool |
|---|---|
| Extract submitted fields | formData.get() or Object.fromEntries(formData) |
| Pass extra, non-field arguments safely | .bind(null, value) on the Server Function |
| Client-side quick validation | Native HTML attributes (required, type, etc.) |
| Server-side authoritative validation | Zod / Valibot inside the action, returned via safeParse |
| Show validation errors in the UI | useActionState |
| Disable a button / show a spinner while submitting | useActionState's pending, or useFormStatus in a child component |
| Instant UI feedback before the server responds | useOptimistic |
| Multiple actions from one form | formAction prop on individual buttons |
| Trigger submission without a click | form.requestSubmit() |
Server Actions don't just remove boilerplate — they change the default behavior of a form from "does nothing until JavaScript finishes loading" to "works immediately, gets progressively better." That's a genuinely different reliability guarantee than the fetch-based pattern most of us learned first, and it's worth building the habit of reaching for <form action={...}> before reaching for onSubmit and a manual fetch call.


