Next.js Server Actions vs API Routes vs tRPC: Choosing the Right Data Mutation Pattern
Every Next.js 15 codebase eventually hosts the same argument. A form needs to write to the database, and there are three reasonable ways to wire that up: a Server Action, a Route Handler, or a tRPC procedure. The Server Actions vs API Routes vs tRPC question comes up in almost every Next.js architecture review we run, usually because a team picked a pattern in week one and started paying for that choice in month six.
All three are legitimate tools. They fail in different places, though, and those failures rarely show up before production traffic does. This post walks through what each pattern is good at, the code shape in Next.js 15 with React 19, the security checks each one needs, and the default we recommend for B2B SaaS projects.
Server Actions vs API Routes: the difference that matters
Strip away the syntax and the two patterns differ in one important way: who owns the contract.
A Server Action has no public contract. The compiler generates an RPC endpoint for you, the function signature lives next to the component that calls it, and the only consumer is your own React tree. Rename an argument and TypeScript updates every call site. There is no URL to document because you never see one.
A Route Handler is the opposite. You define a URL, a method, a request shape, and a response shape, and anything that can speak HTTP can call it. The contract is explicit, which means you have to maintain it, version it, and validate everything that arrives.
tRPC sits between the two. Procedures are real HTTP endpoints under the hood, but the client is generated from the router type, so you get the explicit structure of an API with the call-site ergonomics of a function.
| Server Actions | Route Handlers | tRPC | |
|---|---|---|---|
| Callers | Your React app only | Any HTTP client | TypeScript clients sharing the router type |
| Contract | Implicit, compiler-managed | Explicit URL + schema | Typed procedures |
| Streaming responses | No | Yes | Limited (subscriptions need extra setup) |
| Error typing | Coarse | Whatever you design | Typed error shapes |
| Testing in isolation | Awkward | Plain HTTP tests | Straightforward with a test caller |
| Backend requirement | Next.js server | Next.js server | Node.js runtime |
Where Server Actions earn their keep
For form submissions and simple, single-purpose mutations, Server Actions are hard to beat on effort. A create-project flow needs no endpoint file, no fetch wrapper, and no client state library:
// app/projects/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { requireUser } from '@/lib/auth'
import { projectSchema } from '@/lib/schemas'
export async function createProject(formData: FormData) {
const user = await requireUser()
const parsed = projectSchema.parse(Object.fromEntries(formData))
await db.project.create({ data: { ...parsed, ownerId: user.id } })
revalidatePath('/projects')
}
Pair it with useActionState in React 19 and you get pending states and progressive enhancement without extra wiring. Forms keep working before hydration, which still matters on slow connections.
The limitations are just as concrete. Actions cannot stream a response, so anything that produces incremental output, an LLM completion for example, needs a Route Handler instead. Return values must survive serialization. Errors thrown in production cross the boundary as a generic message, so fine-grained error handling means returning discriminated unions rather than throwing. Actions from a single client run sequentially, which surprises teams that fire several in parallel. And because an action needs the framework around it, unit testing it in isolation is clumsier than testing a plain function.
We covered the production side of this in more depth in Server Actions in production, including duplicate submissions and optimistic UI.
When a Route Handler is the right call
The moment a mutation has a consumer that is not your own React tree, you want a real endpoint. Mobile apps, partner integrations, incoming webhooks from Stripe or GitHub, and anything a customer might script against all need a URL with a documented shape:
// app/api/v1/projects/route.ts
import { NextResponse } from 'next/server'
import { authenticateRequest } from '@/lib/auth'
import { projectSchema } from '@/lib/schemas'
export async function POST(request: Request) {
const user = await authenticateRequest(request)
if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const body = projectSchema.safeParse(await request.json())
if (!body.success) {
return NextResponse.json({ error: body.error.flatten() }, { status: 422 })
}
const project = await db.project.create({ data: { ...body.data, ownerId: user.id } })
return NextResponse.json(project, { status: 201 })
}
You write more code, and in exchange you get things Server Actions cannot give you: streaming responses, proper status codes, response caching, a path you can version (/api/v1/), and a surface you can test with nothing but an HTTP client. Rate limiting and idempotency keys also fit naturally here, since you control the raw request.
The cost is drift. Nothing forces the client and the handler to agree on shapes, so schema validation on both sides stops being optional.
What tRPC adds, and what it costs
tRPC closes exactly that drift. The router is the single source of truth, the client is inferred from its type, and a breaking change on the server turns into a compile error in the component that calls it:
// server/routers/project.ts
export const projectRouter = router({
create: protectedProcedure
.input(projectSchema)
.mutation(({ ctx, input }) => {
return ctx.db.project.create({
data: { ...input, ownerId: ctx.user.id },
})
}),
})
On top of the type safety you get React Query integration, so caching, retries, and invalidation come from a mature library instead of hand-rolled hooks.
The costs are real. tRPC assumes a Node.js (or edge-compatible) backend that shares types with the frontend. If your API lives in Symfony, as it does for many of our clients, tRPC is simply off the table for that boundary; you would generate TypeScript types from an OpenAPI spec instead. It also adds a layer every new developer has to learn, and procedures are not consumable by third parties without an extra REST facade. For a small app with a dozen mutations, the setup rarely pays for itself. For a large all-TypeScript codebase with several developers touching the API weekly, it usually does.
The security check for each pattern
Server Actions look like local function calls, and that is precisely the trap. Every exported action is a public HTTP endpoint. The framework checks the request origin against the host to block cross-site POSTs, but that check does nothing about authorization, so the first line of every action body should establish who is calling and whether they may do this. Treat every argument as untrusted input, because attackers are not limited to the values your form renders. If you run behind a proxy, confirm the forwarded host headers are set correctly or the origin check itself gets confused.
Route Handlers using cookie-based sessions need CSRF protection you build yourself, since Next.js does not add any for handlers. Token-based auth sidesteps that but raises the usual questions about storage and rotation. Either way, validate the payload with a schema before it touches the database.
With tRPC, concentrate authentication in the context factory and authorization in middleware like protectedProcedure, rather than sprinkling checks inside individual resolvers. Centralized middleware is the main security argument for the pattern: an unprotected procedure stands out in review.
Our default for B2B SaaS projects
For a typical B2B SaaS on Next.js 15 we recommend a boring split. Use Server Actions for internal, form-shaped mutations: settings, CRUD screens, onboarding steps. Use Route Handlers for every boundary that outsiders touch, which means webhooks, public APIs, file uploads, and streaming. Add tRPC only when the team is all-TypeScript, the backend is Node, and the API surface is big enough that type drift between client and server has already bitten you.
Mixing patterns inside one app is fine and normal. The mistake we flag in code reviews is exposing the same mutation through two patterns at once, because the second path is where the authorization check goes missing.
Pattern choices like this one compound over a project's lifetime, which is why they are worth an hour of deliberate thought early. It is the kind of decision we work through with clients in tech stack strategy engagements, and in custom software development projects we apply the split above unless something specific argues against it.
If you are staring at a Next.js codebase where mutations grew organically across all three patterns and nobody remembers why, we can help you untangle it. Write to hello@wolf-tech.io or visit wolf-tech.io.
FAQ
Can I use Server Actions and Route Handlers in the same Next.js app?
Yes, and most production apps should. Keep Server Actions for mutations only your own components trigger, and give anything external a Route Handler. Just avoid exposing the same operation through both.
Do Server Actions replace API routes in Next.js 15?
No. Server Actions cover the internal form-and-mutation use case well, but Route Handlers remain the only option for streaming, webhooks, versioned public APIs, and non-React clients.
Does tRPC work with a Symfony or PHP backend?
Not across that boundary. tRPC needs shared TypeScript types between client and server. With a Symfony API, generate types from an OpenAPI spec and call the API from Route Handlers or server components instead.

