Skip to content
← writing
Engineering·
8 min read

Server Actions Ate My API Layer: Six Months of Next.js in Production

What if the form just called your function — no fetch, no JSON, no route file? After six months of shipping server actions in production, here's where they win, where they don't, and how I decide which to use.

  • #nextjs
  • #react
  • #patterns
  • #fullstack
  • #production

For most of my career, the deal was simple: the frontend talks to the backend through HTTP. You write a route on the server (POST /api/contact), you fetch() it from the client, you serialize, you deserialize, you handle the loading state, you handle the error state. Eight files for one form submission, more or less.

It works. It's also a lot.

Server actions are Next.js's bet that most of those eight files don't need to exist. You write a function on the server. You hand it to a <form>. The form posts directly to the function. There's no fetch in your client code, no JSON parsing, no route file. The thing that used to require an API request now looks like a function call.

I've been shipping server actions in production for about six months on this portfolio and a couple of small client projects. Here's what I've learned — what they're great at, where they bite, and the rule I now use to decide which to reach for.

The thirty-second explanation

If you haven't used them, here's what changed.

The old way:

// app/api/contact/route.ts
export async function POST(req: NextRequest) {
  const body = await req.json();
  await sendEmail(body);
  return Response.json({ ok: true });
}

// components/ContactForm.tsx
function ContactForm() {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: FormEvent) {
    e.preventDefault();
    setPending(true);
    try {
      const data = new FormData(e.currentTarget as HTMLFormElement);
      const res = await fetch("/api/contact", {
        method: "POST",
        body: JSON.stringify(Object.fromEntries(data)),
        headers: { "Content-Type": "application/json" },
      });
      if (!res.ok) throw new Error("Failed");
    } catch (err) {
      setError("Something went wrong");
    } finally {
      setPending(false);
    }
  }
  // ... form JSX
}

The server-actions way:

// app/actions.ts
"use server";
export async function submitContact(_prev: State, formData: FormData) {
  await sendEmail(Object.fromEntries(formData));
  return { ok: true };
}

// components/ContactForm.tsx
function ContactForm() {
  const [state, formAction] = useActionState(submitContact, { ok: false });
  return <form action={formAction}>{/* fields */}</form>;
}

The form posts to the function. There's no API route, no fetch, no JSON. React handles the loading state through useFormStatus. Errors come back as a return value.

That's the whole pitch. It looks small. It changes a lot.

Two flowcharts. Left: client code → fetch → API route → handler → response → client state. Right: client code → form action → server function → return value → client state. The right side has fewer hops.
Same outcome, fewer hops. The frontend never knows there was a network request.

What's good (the wins)

1. The boilerplate is gone

The biggest, most immediate win is what isn't there anymore. No fetch wrapper. No JSON parsing. No serialization concerns. No error-handler middleware. The function the form calls is the same kind of function you'd write in a backend service: takes input, does work, returns output. The fact that it travels over HTTP is the framework's problem, not yours.

I count files. The old way: 1 route file, 1 form component, 1 API client wrapper, often a separate types.ts for request/response. The new way: 1 function, 1 form component. Half the files, usually less code in the files that remain.

Side-by-side file count: API route requires 4 files totalling ~80 lines; server action requires 2 files totalling ~30 lines.
Same contact form. Two implementations. Roughly half the surface area.

2. Type safety end-to-end without codegen

If your action signature is (state: State, formData: FormData) => Promise<NewState>, the form component sees that exact type. Rename a field on the server, the client refuses to compile. No tRPC. No OpenAPI. No npm run generate-types step.

This sounds incremental. It isn't. The cost of keeping client and server types in sync is one of the silent taxes of a separated frontend/backend stack. Server actions don't pay it.

3. Progressive enhancement actually works

The form attribute action={formAction} is real HTML. If JavaScript fails to load — bad connection, blocked script, ad-blocker that's a little too aggressive — the form still posts. The page reloads instead of updating in place, but the action runs. The user can submit a contact form even when your client-side React is dead.

I haven't shipped a "JS-disabled" experience deliberately in years. With server actions, I get one for free. That changes my mental model of what counts as a graceful degradation.

Two parallel paths: with JS, react intercepts the submission and the action runs in place; without JS, the browser POSTs the form natively, the action runs, the page does a full reload. Same outcome.
Both paths land in the same action. The only thing that changes is whether the page reloads.

4. Mutations live next to the data they mutate

This is the structural win nobody talks about. Server actions can sit in the same file as the page they belong to. The mutation lives next to its caller, not in a parallel /api directory tree.

// app/posts/[id]/page.tsx
export default async function Post({ params }) {
  const post = await db.post.findUnique({ where: { id: params.id } });

  async function deletePost() {
    "use server";
    await db.post.delete({ where: { id: post.id } });
    redirect("/posts");
  }

  return <DeleteButton onConfirm={deletePost} title={post.title} />;
}

Reading this, the relationship is obvious: this page renders a post, and it has a delete action. No hunting through /api/posts/[id]/route.ts to figure out what the button does.

For internal mutations on a single page, this is dramatically better than a separate route.

What's bad (the losses)

1. They're invisible to anyone outside the app

A server action isn't an API. It doesn't have a stable URL. It doesn't have a documented contract. It can't be called from a mobile app, a webhook, or a curl command in your runbook.

If you ever need to call this same logic from somewhere that isn't the same Next.js app, you have to extract it into a real API route — which means undoing some of the "boilerplate is gone" win.

The rule I've settled on: if the operation might ever be called from outside this app, write it as an API route. Server actions are for internal forms. APIs are for everything that crosses an app boundary.

2. Caching and headers are awkward

A normal API route lets you set Cache-Control, return custom status codes, set cookies on the response. Server actions can do most of these things, but the ergonomics are worse. You're working with cookies() and revalidatePath() instead of a Response object you can shape directly.

For 90% of mutations, this is fine. For the 10% where you genuinely need to control the response — file downloads, custom redirects with specific headers, anything that bridges to a non-React client — server actions are the wrong tool.

3. Debugging is awkward

When something goes wrong with a fetch, you open the Network tab. Request, response, payload, status — all there. Server actions don't show up the same way. They serialize internally over Next's RSC protocol, which is opaque in the browser devtools.

Six months in, I still occasionally drop in a console.log on the server side because the Network tab can't tell me what payload the action received. The DX is better on the happy path and worse on the debugging path.

4. The "where is the API" problem at scale

In a small app, server actions are great. In a large app, they create a coordination problem: every team writes their own actions, they're scattered across feature folders, there's no central "API surface" to reason about.

This isn't a hypothetical. The first medium-sized app I shipped with server actions ended up with the team asking: "how do we know what mutations exist? How do we audit who can do what? How do we add rate limiting to all of them?" All of those problems have answers — a shared withAuth(action) helper, a central rate-limit middleware, a registry — but they're not free, and they're things you'd have gotten "for free" by having a centralized API layer.

If you're building a large app, you may end up rebuilding API-route ergonomics on top of server actions. At that point, ask whether you should have just written the API.

My rule, after six months

I default to server actions for internal forms with a single caller and to API routes for anything else.

The decision tree:

  • One Next.js form, one user, one action? Server action. (Contact form, login, profile update.)
  • Mutation needs to be called from a mobile app, webhook, or external system? API route.
  • Mutation needs custom response shape (file download, specific headers, cookies-only response)? API route.
  • Mutation is internal but I can already see five places it'll be called from? API route — it's becoming a service, treat it like one.

Both can coexist in the same app. They don't have to fight. Server actions for the leaves, API routes for the trunk.

The big-picture take

Server actions aren't a replacement for APIs. They're a replacement for the thin client wrapper around an API call that exists only because the form is in JavaScript. That layer was always boilerplate. Now it's gone.

Where they shine, they're transformative — the contact form on this portfolio went from ~80 lines spread across 4 files to ~30 lines in 2 files, and that's including the rate limiting and Turnstile validation. That's not a marginal improvement.

Where they don't fit, the framework gives you the API route system to fall back on. Use it. The skill is knowing which is which — and the answer almost always becomes obvious once you ask: "will this ever be called by something other than a <form> in this app?"

If yes, write the API. If no, write the action. Move on.

A two-branch decision tree starting from 'will it ever be called by something other than a form in this app?' — no branches to server action with examples; yes branches to API route with examples. Bottom note: both can coexist.
One question decides. Don't force everything through one tool.

/share