Server Actions started as an RSC implementation detail. A way to call a server function from a client component without writing a fetch handler, without inventing an endpoint, without naming a route. Three years later the feature has outgrown that frame. Half the React ecosystem now treats Server Actions as the primary transport for mutations, the other half is shipping libraries that look suspiciously like a typed RPC layer wrapped around the same primitive.

That is the interesting shift. Server Actions are not a feature anymore. They are a transport. And the libraries piling up on top, next-safe-action, zsa, orpc, the Conform and TanStack Form glue, the validation adapters, are all trying to answer the same question: what is the right shape for a typed function call that crosses the network, when the network call is invisible?

How the primitive escaped its original use case

The original pitch for Server Actions was narrow. Inside a Server Component, you mark a function with "use server", pass it to a <form action={...}>, and the framework wires up the network call. The mental model was forms. Progressive enhancement. The Remix loader/action pattern, folded into React itself.

That framing lasted about as long as it took the first team to try it for something other than a form. Once Server Actions were callable from any client event handler, the form story stopped being the story. People started using them as the default way to mutate data from the client. Then as the default way to read data on demand. Then as the wire format for optimistic UI, for revalidation, for streaming progress out of long-running jobs.

By the time Next.js 16 shipped, the action surface had picked up most of what you would expect from a real RPC system: middleware, input validation, typed errors, server-side context propagation, cookie-aware authorization. None of that is in React itself. It is bolted on by userland libraries, and those libraries are the actual product now.

What is actually on the wire

It helps to remember what Server Actions are at the network layer, because the abstraction hides a lot. A Server Action call from the client is a POST to the same route the user is currently on, with a special header (Next-Action in Next.js, or the framework's equivalent), and a body that contains the serialized arguments. The framework looks up the action by an opaque ID, deserializes the arguments through the React Flight protocol, runs the function, and serializes the return value back.

There is no route file. No OpenAPI schema. No explicit endpoint. The action ID is generated at build time and embedded in the client bundle as a closed-over reference. When you import a server function in a client component, what actually lands in the client bundle is a stub that calls the framework's dispatcher with the right ID and arguments.

That is convenient. It is also why the security model is so easy to get wrong. Every exported "use server" function is a public endpoint. The framework will happily route any well-formed POST to it from any caller, authenticated or not. There is no implicit authorization. The closure that contains the action does not gate access to it. If you export an action that reads userId from session and trusts the client to also pass a targetUserId, you have shipped an IDOR.

This is the pattern behind the Next.js CVE batch earlier this year. The vulnerabilities were not in the framework's transport. They were in how the framework's transport interacts with code that was written under the assumption that an action is a private function. It is not. It is a route.

Why three libraries exist for the same job

If Server Actions are public endpoints, every action needs the same scaffolding any other endpoint needs: input validation, authentication, authorization, rate limiting, observability, typed error responses. React does not give you any of that. Next.js gives you almost none of it. So the ecosystem filled the gap, three times, with subtly different opinions.

The three serious contenders are next-safe-action, zsa, and orpc. They look superficially similar. All of them wrap your server function with a validation step and a typed client. But they make different bets about what a Server Action is for.

LibraryPrimary betValidationTransportWhat it gives up
next-safe-actionActions are typed formsZod, Valibot, Yup via adapterNext.js Server Actions onlyPortability outside Next.js
zsaActions are typed proceduresZod-firstServer Actions, route handlers, server componentsForm-shaped ergonomics
orpcActions are one transport among manyZod, Valibot, ArkType, Effect SchemaServer Actions, fetch, OpenAPI, contractsFramework-native feel

next-safe-action stays closest to the original mental model. The library treats the action as a typed form handler. The schema describes the form data. The client hook (useAction) tracks pending state, result, error, and revalidation. If your app's mutations actually are forms, this is the least friction option.

zsa moves further from forms. The shape is closer to tRPC. You define a procedure with input validation, middleware chains, and typed errors, then call it as a plain async function from the client. You can still use it from a form, but the library does not assume you will. zsa is what you reach for when the action is doing real work that is not submit this form, background jobs, multi-step flows, anything where the form metaphor stops helping.

orpc is the most ambitious of the three. It treats the Server Action as one of several transports for the same procedure definition. The same contract can be called as a Server Action from a React client, as a typed fetch from a non-React client, as an OpenAPI endpoint from an external service, or as a contract-tested RPC inside a monorepo. The Server Action is no longer the primitive. The contract is.

Top-down editorial schematic showing three layered translucent decks stacked at slight rotational offsets, each deck containing the same circular set of input nodes but routed through different connector geometries, curved lanes for the first deck, straight rails for the second, branching tree paths for the third, set against a soft cream background with faint dotted gridlines, monochrome deep-teal linework with a single coral accent highlighting one node per deck, isometric vector-illustration style, matte paper texture, 4:3 aspect, no text, no logos, no watermarks, no UI chrome.

The typed-error problem nobody solved cleanly

Every one of these libraries hits the same wall when you ask what an action should return on failure. The naive answer is throw. React will catch it, the framework will serialize a generic error, and the client gets a 500. That is fine for unexpected failures. It is the wrong shape for expected ones, the user already has an account, the coupon code is invalid, the resource is locked, because those are not exceptions. They are part of the API.

The libraries diverge here. next-safe-action returns a result object with data and serverError and validationErrors siblings, modeled loosely on Remix's data/error split. zsa returns a [data, error] tuple in the Go style, with typed error classes. orpc lets you define a contract-level error type that is type-safe on both ends and serialized through the same path as the success value.

None of these is wrong. The pattern matters more than the shape. Every library has been forced to invent a parallel typed channel for expected failures, because throw does not carry types across the network. Typed RPC libraries solved this years ago. The fact that Server Actions had to relitigate it is a tell. The primitive is too thin to be useful on its own.

What this means for picking one

The decision is less about features and more about which constraint you cannot break.

  • If your app is Next.js, your mutations are mostly forms, and you do not expect to leave the framework: next-safe-action. It is the smallest dependency that fixes the security and validation gaps without changing how you think about the feature.
  • If your app is Next.js but your actions are doing more than submitting forms, background work, multi-step procedures, things that want middleware chains and typed error classes: zsa. It is the closest thing to tRPC that still uses Server Actions as the wire.
  • If you have non-React clients in the picture, or you expect to outgrow one framework, or you want OpenAPI fall-out from the same contract: orpc. The Server Action becomes one of several call sites for a single procedure definition.

There is also the do-nothing option, which is more defensible than it looks. If your team is small, your action surface is small, and you can keep the discipline of validating every input with a safeParse call at the top of every action, you do not strictly need a library. You will write the same five lines of boilerplate at the top of every function, but you will not take on a dependency that ties you to one library's opinions about result shape.

The reason most teams do not stay in that camp for long is that the boilerplate is not just validation. It is validation, plus auth, plus rate limiting, plus logging, plus error mapping, plus revalidation. Each of those is the kind of thing you write once correctly and then forget. The first time someone copy-pastes an action and removes the auth check by accident, the library starts looking cheap.

Build-time costs nobody warns you about

Server Actions are not free at build time, and the cost scales with how many of them you have. Every "use server" function gets an action ID generated, gets pulled into a separate module graph from the client bundle, and gets a stub injected on the client side. The reference between the client stub and the server function has to survive bundling, minification, and caching.

In a small app this is invisible. In an app with a few hundred actions and a Turbopack or Webpack pipeline that is already busy with RSC, the action graph starts showing up in build profiles. It also shows up at runtime in the action ID lookup table that ships with the client bundle. Most apps will never notice. The ones that do tend to be the ones using Server Actions as their default mutation transport, exactly the use case the libraries above encourage.

The fix is not exotic. It is the same fix you would apply to any large endpoint surface: collapse actions that are doing the same job, push pure computation back to the server component that called them, and stop using actions for things that should be plain server-side reads. Server Actions are good for mutations. They are not a substitute for data fetching, even though the ergonomics make them feel like one.

Where this is heading

The React team has been clear that Server Actions are a low-level primitive, not an opinionated framework. That is consistent with how React has always shipped. Give you the wire, let userland argue about the protocol. The argument is happening right now, in public, across three libraries with overlapping APIs and slightly different bets about what a procedure call should look like.

The likely outcome is convergence without consolidation. The libraries will keep their distinct opinions, forms-first, procedure-first, contract-first, but their result shapes will drift toward each other under pressure from teams that switch between them. The validation adapter pattern is already a de facto standard. Typed errors are next. Middleware chains after that.

What is unlikely is for React or Next.js to absorb any of this into the framework. There is no version of the framework that picks one library's opinions without breaking the others. The most useful thing the framework can do is keep the transport stable, keep the action ID generation predictable, and let the ecosystem figure out the rest. So far that is what is happening.

The interesting question for the next year is not which library wins. It is whether teams start treating Server Actions as the default transport for mutations even outside Next.js. TanStack Start already supports a similar pattern. Remix folded back into React Router with the same mental model. The pattern is portable. The libraries are not yet, but orpc is the obvious bet on that bet eventually paying off.

For now, the practical position is unglamorous. Pick the library that matches the shape of your mutations, treat every action as a public endpoint when you write it, and remember that the closure around your "use server" function is not a security boundary. The boundary is in your validation step. If you do not have one, you have shipped an API without an API.