Next.js Server Actions vs tRPC: how to actually choose
If you are building a Next.js app with the App Router and trying to decide between Server Actions and tRPC for your mutations, here is the short version: reach for Server Actions when you have simple forms tied to a single web client, and reach for tRPC once you need request batching, query caching, or a shared API layer across web and mobile. Both solve the same underlying problem, getting typed data from the browser to the server without hand rolling REST endpoints. The right pick depends less on which one is trending and more on what your app actually needs to scale.
The mutation problem both tools solve
Every full stack app eventually answers the same question: how do you move data from a client component to a database, safely, with types that do not lie to you. In the Pages Router era that meant API toutes, fetch calls, and manually keeping request and response types in sync by hand. React 19 changed the equation by building Server Actions directly into React itself. Around the same time, tRPC matured into the default choice for teams who wanted end to end type safety without giving up a real API layer.
Both approaches let you call server code from the client like it is a regular function. The difference shows up once your app grows past a single form.
How Server Actions work, and what React 19 added
A Server Action is a function marked with the "use server" directive. You call it directly from a form or an event handler, and Next.js handles the network request, serialization, and cache revalidation for you.
// app/actions/create-post.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
if (!title || title.length < 3) {
return { error: "Title needs at least 3 characters" };
}
await db.post.create({ data: { title, body } });
revalidatePath("/posts");
return { success: true };
}
Wire it to a form and you get a working mutation with zero client side JavaScript required for the base case:
// app/posts/new/page.tsx
import { createPost } from "@/app/actions/create-post";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Body" required />
<button type="submit">Publish</button>
</form>
);
}
Forms built this way work without any client side JavaScript at all, which is a real win for accessibility, resilience on flaky connections, and Core Web Vitals. React 19 adds two hooks that close most of the gap Server Actions used to have against client state libraries: useActionState for tracking the pending, error, and result state of a mutation, and useOptimistic for showing the new state immediately while a mutation is still in flight.
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions/create-post";
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Body" required />
<button type="submit" disabled={isPending}>
{isPending ? "Publishing..." : "Publish"}
</button>
{state?.error && <p role="alert">{state.error}</p>}
</form>
);
}
That closes most of the reason people used to bolt on a separate API layer just to get loading and error states on a form.
How tRPC works, routing, type safety, batching
tRPC takes a different approach. Instead of individual server functions sprinkled through your app, you define a router of typed procedures, and the client gets full autocomplete and type checking without writing a schema or generating code.
// server/routers/post.ts
import { z } from "zod";
import { publicProcedure, router } from "../trpc";
import { db } from "@/lib/db";
export const postRouter = router({
create: publicProcedure
.input(z.object({ title: "z.string().min(3), body: z.string() }))"
.mutation(async ({ input }) => {
return db.post.create({ data: input });
}),
list: publicProcedure.query(async () => {
return db.post.findMany({ orderBy: { createdAt: "desc" } });
}),
});
On the client, calling that mutation looks like a normal function call, fully typed end to end:
"use client";
import { trpc } from "@/lib/trpc-client";
export function PostForm() {
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: () => utils.post.list.invalidate(),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
createPost.mutate({
title: "form.get(\"title\") as string,"
body: form.get("body") as string,
});
}}
>
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Body" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? "Publishing..." : "Publish"}
</button>
</form>
);
}
tRPC has request batching and query caching built in, neither of which Server Actions gives you on its own. If your app has a dashboard firing several queries at once, or you are serving both a web client and a mobile client from the same backend, that caching and batching layer stops being optional pretty fast.
Key differences: progressive enhancement vs supporting multiple clients
The two tools optimize for different failure modes.
| Server Actions | tRPC | |
|---|---|---|
| Works without JavaScript | Yes, forms submit natively | No, needs the client runtime |
| Type safety | Yes, through TypeScript function signatures | Yes, through inferred router types |
| Request batching | Not built in | Built in |
| Query caching | You wire it yourself with revalidatePath or a library |
Built in, backed by React Query |
| Best for | Web only apps, forms, simple mutations | Apps serving web and mobile, complex dashboards |
| Setup cost | Near zero, it is just a function | A router, a client, and some wiring |
Server Actions win on simplicity and progressive enhancement. tRPC wins the moment your data layer needs to serve more than one kind of client, or your queries get complex enough that manual cache invalidation turns into a chore.
The hybrid pattern: combining both
You do not have to pick exactly one. A pattern that holds up well in production: use Server Actions for the simple, form driven mutations that benefit from progressive enhancement, and keep tRPC around for anything read heavy, batched, or shared across clients.
// app/actions/create-post.ts
"use server";
import { appRouter } from "@/server/routers/_app";
import { createCallerFactory } from "@/server/trpc";
const createCaller = createCallerFactory(appRouter);
export async function createPost(formData: FormData) {
const caller = createCaller({});
return caller.post.create({
title: formData.get("title") as string,
body: formData.get("body") as string,
});
}
This gives you a Server Action as the entry point, so the form still works without client side JavaScript, while the actual mutation logic lives in exactly one place, the tRPC router. You get progressive enhancement on the surface and a reusable, structured API underneath that your mobile app, or any other client, can call directly.
Decision table: pick based on your app type
| Your situation | Pick |
|---|---|
| Marketing site with a contact form | Server Actions |
| Internal admin panel, web only | Server Actions, add tRPC later if it grows |
| SaaS product with a web app and a mobile app | tRPC, or the hybrid pattern |
| Dashboard with many simultaneous queries | tRPC |
| You want the smallest possible setup | Server Actions |
| You already have a tRPC router from a previous project | Keep it, add Server Actions only where forms need progressive enhancement |
None of this is permanent. Plenty of teams start with Server Actions because it is the fastest way to ship a form, then bring in tRPC once the query side of the app gets complicated enough to need caching.
FAQ
Are Server Actions replacing tRPC?
No. Server Actions replace a lot of the small API routes that used to exist only to handle form submissions. tRPC still does something Server Actions do not: give you a typed, cacheable, batchable API surface that more than one client can call.
Can you use Server Actions with tRPC?
Yes, and it works well together. Call your tRPC router directly from inside a Server Action using a server side caller, as shown above. You keep one source of truth for your mutation logic and still get progressive enhancement on the form itself.
What is the difference between tRPC and Server Actions?
Server Actions are a React and Next.js primitive, a function that runs on the server and can be called from a form or event handler. tRPC is a full API layer with routing, input validation, batching, and caching, callable from anywhere, not only from React components.
If you want a deeper look at wiring Server Actions into real production forms, with validation and redirects, I cover it in more detail in Server Actions in Next.js.
If you want this wired up on your own AI product end to end, that is exactly the kind of work I take on.
Drop a comment if your team landed somewhere different. Curious what tipped the decision for you.
Top comments (0)