10LOC
Reactintermediate

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

  • subscribeToNewsletter is a normal async function whose 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 action is the function itself, React automatically wraps the submission in a transition and passes the submitted FormData as 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 email throws before anything touches storage.
  • NewsletterForm needs no onSubmit, 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: true rather 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 useActionState in 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 before formData in 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.