Skip to content

React Server Actions: Mutations Without API Routes

How React Server Actions Replace API Routes

React Server Actions let a form submit directly to server code, no fetch call and no API route. Here's when that's the right call and when it isn't.

· · 4 min read

Quick Take

I rewrote a settings form last month and deleted an entire API route file in the process. Server Actions replaced it with a function, called directly from the form, no client-side fetch in sight.

The API route was never really the point, it existed to give client code something to fetch(). Server Actions remove the middle step: the function you'd have put in that route now runs directly when a form submits, with the request/response plumbing handled by the framework instead of hand-written by you.

TL;DR: A Server Action is an async function marked 'use server' that a client component can call directly, most often via a form's action prop. React handles the network round trip. Use Server Actions for mutations tied to your own UI (forms, button clicks); keep API routes for anything a mobile client, webhook, or third party needs to call independently.

The Old Way: Form, Fetch, API Route

A settings update used to mean three separate pieces of code that all had to agree on the same shape:

// 1. Client component with a fetch call
function SettingsForm() {
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget as HTMLFormElement);
    await fetch('/api/settings', {
      method: 'POST',
      body: formData,
    });
  }
  return <form onSubmit={handleSubmit}>{/* fields */}</form>;
}

// 2. API route handling the same shape
export async function POST(req: Request) {
  const formData = await req.formData();
  await db.settings.update({ name: formData.get('name') });
  return new Response(null, { status: 204 });
}

Two files, one implicit contract between them, and no compiler check that they stay in sync if a field gets renamed.

The Same Thing With a Server Action

// actions.ts
'use server';

export async function updateSettings(formData: FormData) {
  const name = formData.get('name') as string;
  await db.settings.update({ name });
}
// SettingsForm.tsx
import { updateSettings } from './actions';

function SettingsForm() {
  return (
    <form action={updateSettings}>
      <input name="name" />
      <button type="submit">Save</button>
    </form>
  );
}

One function, imported directly into the component that uses it. No fetch(), no route file, no separate URL to keep in sync. The 'use server' directive tells the bundler this function's code stays on the server, the client only gets a reference it can call.

Getting Pending State and Errors: useActionState

Real forms need a loading state and error handling, not just a fire-and-forget submit. useActionState wraps a Server Action and gives you both:

'use client';

import { useActionState } from 'react';
import { updateSettings } from './actions';

function SettingsForm() {
  const [state, formAction, isPending] = useActionState(
    async (_prevState: { error?: string }, formData: FormData) => {
      const name = formData.get('name') as string;
      if (!name.trim()) {
        return { error: 'Name cannot be empty' };
      }
      await updateSettings(formData);
      return { error: undefined };
    },
    { error: undefined },
  );

  return (
    <form action={formAction}>
      <input name="name" disabled={isPending} />
      {state.error && <p role="alert">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
}

isPending tracks the in-flight request automatically, no manual useState toggle before and after the call. The validation logic lives right next to the mutation it guards, which is where I'd want it during a code review anyway.

Progressive Enhancement Is the Underrated Win

Because a Server Action attached to a form's action prop is a real HTML form submission under the hood, the form still works if JavaScript hasn't loaded yet, a slow connection, an ad blocker interfering, or a bug in an unrelated script. A fetch()-based onSubmit handler has no such fallback, if the JS bundle didn't execute, the button does nothing.

This isn't theoretical. I've watched real session recordings where a user on a spotty connection submitted a form before the JS bundle finished loading. With a Server Action, the browser's native form submission carried it through. With a fetch() handler, it silently failed.

When to Keep an API Route Instead

ScenarioUse
Form submit from your own React UIServer Action
Button click that triggers a mutationServer Action
Webhook receiver (Stripe, GitHub)API route
Endpoint called by a mobile appAPI route
Public API consumed by third partiesAPI route
Cron job hitting an endpointAPI route

The dividing line is ownership: if your React app's UI is the only caller, a Server Action removes a layer of indirection for free. If anything outside your UI needs to call it, that's what a stable, versioned API route is for.

Conclusion

Server Actions aren't a replacement for every API route, they're a replacement for the ones that existed purely to give a form or button something to fetch(). The settings form example above went from two files with an implicit contract to one function imported directly where it's used, with useActionState handling the pending and error states that used to need manual useState bookkeeping.

Frequently Asked Questions

What is a React Server Action?
A Server Action is an async function marked with the 'use server' directive that runs only on the server, but can be called directly from client components as if it were a local function, most commonly by passing it to a form's action prop. React handles the network call behind the scenes, serializing the form data and the function reference, so you never write a fetch() call or an API route handler for that mutation.
Do Server Actions replace API routes entirely?
No. Server Actions are best for mutations triggered by your own UI, form submits, button clicks tied to a specific component. API routes are still the right tool when you need a stable public endpoint, a webhook receiver, or an interface consumed by something other than your React app, a mobile client, a third-party integration, or a cron job. Keep API routes for anything outside your own app's UI.
Do Server Actions work without a form?
Yes. A Server Action is just an async function, you can call it from a button's onClick handler wrapped in startTransition, or from any client-side event, not only a form's action prop. Forms are the common case because progressive enhancement works there for free, a form with a Server Action still submits even if JavaScript hasn't loaded yet.