A form calling a Server Action directly, no client fetch
Published June 1, 2027
const subscribers = new Set<string>();
async function subscribeToNewsletter(formData: FormData) {
"use server";
const email = formData.get("email");
if (typeof email !== "string" || !email.includes("@")) {
throw new Error("Enter a valid email address");
}
subscribers.add(email);
}
export const NewsletterForm = () => (
<form action={subscribeToNewsletter}>
<input type="email" name="email" required placeholder="you@example.com" />
<button type="submit">Subscribe</button>
</form>
);What
A <form>'s action can point directly at a Server Action — an async function marked "use server" — so submitting the form calls server code without the page ever writing a fetch, a JSON body, or an API route to receive it.
Why it matters
The traditional flow — onSubmit handler, preventDefault, serialize the form, fetch a route handler, parse the response, update state — is a lot of boilerplate just to move a FormData from a browser to a server function. A Server Action collapses that: the framework turns the "use server" function into a callable reference the client can invoke directly as if it were local, and passing it as a form's action wires up the submission automatically, including working without JavaScript at all as a progressively-enhanced plain HTML form post.
How it works
subscribeToNewsletteris a normalasync functionwhose first statement is the string literal"use server"— that directive is what marks it as a Server Action rather than regular code that happens to run on the server.- Because the form's
actionis the function itself, React automatically wraps the submission in a transition and passes the submittedFormDataas the function's argument — no manual serialization. - Validation happens on the server, where it can't be bypassed by disabling client-side JavaScript: a malformed
emailthrows before anything touches storage. NewsletterFormneeds noonSubmit, no client-side state, and — notably — no"use client"directive; it can stay a plain server-rendered component since the interactivity lives entirely in the platform's native form submission.
Gotchas
- Server Actions run with the server's privileges, not automatically scoped to the submitting user — always re-check authentication/authorization inside the action itself, even if the form is only rendered behind a login wall. Real validation plus real persistence is why this snippet keeps
lineWaiver: truerather than trimming down to a bare, unchecked form post. - With no client-side feedback wired up, a failed submission (a thrown error) surfaces as a full error page in the default case; add
useActionStatein a client component if you need inline validation messages instead. - Passing extra context the form doesn't submit (like a resource ID) uses
action.bind(null, id), not a hidden state variable — the bound argument arrives beforeformDatain the function's parameters.
Related
useActionState— layer this on top when you need pending/error state surfaced in the UI instead of a full error boundary.useOptimistic— pairs naturally with a Server Action to show the result of the submission before the server round-trip completes.