TL;DR
tRPC for internal TypeScript full-stack apps — end-to-end type safety with minimal boilerplate. REST for public APIs, multiple client languages, or when you need cache-ability. GraphQL for complex data fetching requirements, multiple clients with different data needs, or large teams. Most indie SaaS boilerplates use tRPC or Server Actions in 2026.
The Shift in 2026
The API landscape in SaaS boilerplates has shifted dramatically:
2020: REST dominates
2022: GraphQL grows (Apollo)
2023: tRPC explodes (T3 Stack popularization)
2024: Server Actions enter (Next.js App Router)
2026: tRPC + Server Actions share the space; REST for public APIs
tRPC: Type-Safe RPCs Without Code Generation
tRPC gives you a type-safe API layer between your Next.js frontend and backend — no schema files, no code generation, no manual type maintenance.
// server/api/routers/post.ts — define procedures
export const postRouter = createTRPCRouter({
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(100),
content: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: {
title: input.title,
content: input.content,
authorId: ctx.session.userId,
},
});
}),
getAll: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.post.findMany({
where: { authorId: ctx.session.userId },
orderBy: { createdAt: 'desc' },
});
}),
});
// Client — full TypeScript inference, no manual type imports
const utils = api.useUtils();
const { data: posts } = api.post.getAll.useQuery();
// posts: Post[] | undefined — TypeScript knows the exact return type
const createPost = api.post.create.useMutation({
onSuccess: () => utils.post.getAll.invalidate(),
});
createPost.mutate({ title: 'Hello', content: 'World' });
// TypeScript validates input shape at compile time
tRPC Advantages
- Zero API maintenance: Change the server return type → TypeScript errors on client immediately
- No fetch boilerplate: No
fetch('/api/posts').then(r => r.json())— just call the procedure - React Query integration: Built-in caching, optimistic updates, background refetch
- Input validation: Zod schemas serve as both TypeScript types AND runtime validation
tRPC Limitations
- TypeScript only (no cross-language clients)
- HTTP/1 limitations (no true HTTP caching, all POSTs)
- No public API: tRPC isn't appropriate for customer-facing APIs
- Context coupling: tightly coupled to your framework/runtime
Server Actions: The App Router Alternative
Next.js Server Actions (stable in Next.js 14+) provide a way to call server functions from Client Components without a separate API layer:
// app/actions/post.ts — Server Actions
'use server';
import { auth } from '@clerk/nextjs/server';
export async function createPost(formData: FormData) {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
const title = formData.get('title') as string;
const content = formData.get('content') as string;
return prisma.post.create({
data: { title, content, authorId: userId }
});
}
// Client Component — calls server function directly
'use client';
import { createPost } from '@/app/actions/post';
export function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" />
<textarea name="content" />
<button type="submit">Create</button>
</form>
);
}
Server Actions are compelling for form-based interactions but lack the caching/invalidation ergonomics of tRPC + React Query.
tRPC vs Server Actions:
- tRPC: better for complex data fetching, React Query caching, optimistic updates
- Server Actions: simpler for form submissions, mutations without optimistic UI
REST: The Reliable Standard
REST remains the right choice for:
- Public APIs that external developers consume
- Mobile clients (React Native, Flutter) where tRPC's coupling is undesirable
- Multiple client languages (a Python data pipeline calling your API)
- Webhooks you receive (Stripe, GitHub — always REST)
// app/api/v1/posts/route.ts — Standard REST endpoint
import { NextRequest } from 'next/server';
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(1),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const result = createPostSchema.safeParse(body);
if (!result.success) {
return Response.json({ error: result.error.flatten() }, { status: 400 });
}
const post = await prisma.post.create({ data: result.data });
return Response.json(post, { status: 201 });
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get('page') ?? '1');
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20'), 100);
const posts = await prisma.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
});
return Response.json({ posts, page, limit });
}
The downside: manual type maintenance. Change the response shape on the server → TypeScript doesn't tell the client until a test fails or a user reports a bug.
GraphQL: Powerful but Heavy
GraphQL excels when multiple clients with different data needs query the same API:
# Client 1 (dashboard) — needs minimal user data
query {
user(id: "123") {
id
name
plan
}
}
# Client 2 (admin) — needs full user data
query {
user(id: "123") {
id
name
email
plan
createdAt
subscription {
status
currentPeriodEnd
}
invoices(limit: 10) {
amount
paidAt
}
}
}
Both queries are served by the same resolver — clients fetch exactly what they need.
GraphQL in boilerplates: Rare. Apollo, Pothos, and similar are used in some enterprise boilerplates. For indie SaaS, the overhead (resolvers, schema, tooling) rarely pays off vs tRPC.
Choose GraphQL when: Multiple first-party clients with different data needs, external developer API with complex querying requirements, or team has existing GraphQL expertise.
Boilerplate API Choices
| Boilerplate | Primary API Layer |
|---|---|
| T3 Stack | tRPC |
| T3 Turbo | tRPC (shared across apps) |
| ShipFast | tRPC + REST (for webhooks) |
| Supastarter | tRPC + Supabase client |
| Epic Stack | Server Actions (Remix form approach) |
| Open SaaS | tRPC |
Decision Guide
Internal TypeScript full-stack app with one client?
→ tRPC (best DX, type safety, React Query)
Need a public API for external developers?
→ REST (OpenAPI spec)
Multiple first-party clients with complex data needs?
→ GraphQL or tRPC with separate client packages
Form-heavy Next.js App Router app?
→ Server Actions (simpler for mutations, pairs with tRPC for queries)
Mobile + web + backend as separate services?
→ REST or GraphQL
Performance Comparison
API architecture choice has real performance implications for SaaS applications:
tRPC over HTTP/1:
- All requests are POST (no GET-based HTTP caching)
- Browser dev tools show opaque
POST /api/trpc/...requests - React Query handles client-side caching, which replaces HTTP caching for most cases
- Batching: tRPC sends multiple queries in a single HTTP request by default
REST with GET:
GET /api/posts?page=1is cacheable at CDN and browser level- Excellent for content that changes infrequently (blog posts, documentation)
- Standard HTTP caching headers (
Cache-Control,ETag) work naturally - Better for public APIs where clients cache responses independently
GraphQL:
- Query complexity can lead to N+1 database queries without DataLoader
- Persistent queries (Apollo) allow CDN-cacheable GET requests
- Subscriptions (GraphQL over WebSockets) enable real-time features
For most SaaS dashboards, tRPC's React Query caching is sufficient. For public-facing content APIs (blog, documentation, content feeds), REST with HTTP caching provides CDN-level performance that tRPC can't match without additional infrastructure. A product with a heavy public content section — marketing pages, a documentation site, a public-facing blog — typically serves that content from a separate CDN-cached layer regardless of which API architecture powers the authenticated dashboard, so the two approaches coexist naturally.
The Hybrid Pattern
Most mature SaaS codebases use a hybrid:
- tRPC for internal dashboard operations: User data, subscriptions, team management — high frequency, auth-protected, type-safe
- REST for webhooks and public APIs: Stripe webhooks (always REST), GitHub webhooks, external developer APIs
- Server Actions for form submissions: Settings forms, profile updates — simpler mutation pattern without React Query overhead
This pattern isn't complexity for its own sake — it uses each tool where it has genuine advantages. The "use tRPC for everything" approach works until you need to document a public API or receive external webhooks, at which point REST becomes necessary anyway.
Adopting tRPC in an Existing REST Codebase
Teams with existing REST APIs don't need to rewrite everything at once. tRPC and REST coexist in Next.js without conflict:
- Add tRPC alongside existing REST routes — they coexist in the same Next.js app at
/api/trpc/[trpc]and/api/* - Migrate type-error-prone endpoints first — complex data shapes and frequently changing response structures benefit most from tRPC's type inference
- Keep REST for webhooks forever — Stripe, GitHub, and other external services send REST webhooks; these should never become tRPC endpoints
- Keep REST for external clients — any endpoint consumed by non-TypeScript code or third-party developers belongs in REST
The incremental migration avoids a big-bang rewrite while capturing tRPC's type-safety benefits in the highest-value places first. A mixed tRPC + REST codebase is intentional and sustainable — not a sign of incomplete migration. Most mature SaaS codebases land here naturally as the product grows.
The practical starting point for migration: identify your five most-edited API routes over the past month. These are the routes most likely to have type drift between server and client. Migrating these five to tRPC eliminates the most common source of runtime type errors and gives the team confidence in the pattern before wider adoption.
Error Handling Patterns
tRPC's error handling is more structured than REST's ad-hoc try/catch:
// tRPC error handling — structured, typed
import { TRPCError } from '@trpc/server';
export const postRouter = createTRPCRouter({
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.id } });
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' });
}
if (post.authorId !== ctx.session.userId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not your post' });
}
return ctx.db.post.delete({ where: { id: input.id } });
}),
});
// Client — TRPCClientError is typed
const deletePost = api.post.delete.useMutation({
onError: (error) => {
if (error.data?.code === 'NOT_FOUND') {
toast.error('Post not found');
} else if (error.data?.code === 'FORBIDDEN') {
toast.error('Permission denied');
}
},
});
tRPC maps server errors to HTTP status codes automatically (NOT_FOUND → 404, FORBIDDEN → 403). REST requires you to set status codes manually and document the error contract separately.
Key Takeaways
- tRPC is the default for 2026 SaaS boilerplates: end-to-end TypeScript inference, no code generation, React Query integration — the best developer experience for internal TypeScript full-stack apps
- REST remains essential for public APIs, webhooks received from external services (Stripe, GitHub), and mobile/multi-language clients where tRPC's TypeScript coupling is undesirable
- GraphQL is justified when multiple first-party clients with different data requirements query the same API; it's overkill for a single SaaS dashboard with one client
- Server Actions (Next.js App Router) are compelling for form-based mutations but lack tRPC's caching and optimistic update ergonomics — the two complement each other rather than compete
- tRPC's POST-only approach means no HTTP-level GET caching; for content feeds or documentation APIs that benefit from CDN caching, REST with proper cache headers is the better choice
- The hybrid pattern (tRPC for internal + REST for external) is how mature SaaS codebases are actually structured — don't force a single choice
- Boilerplates using tRPC ship the most productive developer experience for teams that will never expose a public API; those that need external APIs need REST regardless of their internal choice
Migrating Between API Layers
One practical question teams rarely consider up front: what happens when you need to change your API layer after you've shipped? The migration path varies significantly depending on your current choice.
Moving from tRPC to REST is straightforward — tRPC procedures map cleanly to route handlers. A user.getById procedure becomes GET /api/v1/users/[id]. The main work is adding OpenAPI-compatible response shapes and HTTP status codes. Expect one to two weeks for a medium-sized codebase.
Moving from REST to tRPC is also feasible — you replace fetch calls with typed procedure calls. The benefit is immediate type safety, but you lose public API cacheability if that matters. Most teams doing this migration say it pays off within the first month through reduced type maintenance overhead.
Moving from GraphQL to tRPC is the most disruptive migration — GraphQL's flexible querying patterns don't map directly to tRPC's procedure model. If multiple clients consume the GraphQL API with different query shapes, the migration requires careful audit of which queries each client actually uses before you can design equivalent tRPC routers.
The lesson: choose your API layer early and based on realistic product requirements. The migration cost is real but manageable for tRPC-REST. GraphQL migrations are more complex and should prompt careful consideration before choosing GraphQL in the first place.
Choosing the Right API Layer for Your Team
Beyond the technical differences, team composition shapes which API layer works best in practice. tRPC's biggest benefit — end-to-end TypeScript inference — only materializes when your entire team uses TypeScript. A backend engineer writing tRPC procedures in TypeScript while a frontend contractor writes vanilla JavaScript fetch calls loses most of the type-safety benefit. In that scenario, a REST API with an OpenAPI spec that generates client types might deliver better results.
REST's lingua franca advantage matters more than most TypeScript-first developers admit. REST endpoints are immediately understandable to any developer, regardless of their TypeScript fluency. When you hire a data engineer to build a Python pipeline that queries your product data, they'll hit your REST endpoints without any TypeScript tooling. When a growth engineer sets up a Zapier integration, they need REST. When you partner with another SaaS for a mutual integration, they expect REST webhooks or a REST API. Building tRPC-only means you'll add REST later regardless — the question is whether to plan for it from the start.
Server Actions reduce the surface area for which you need tRPC at all. For mutations — creating records, updating settings, submitting forms — Server Actions with Zod validation are simpler and sufficient. Reserve tRPC for complex queries with caching requirements, optimistic updates, or data that multiple components share via React Query's cache. The combination of Server Actions for mutations and tRPC for queries is a pattern gaining traction in 2026 boilerplate communities.
Testing API Layers
The testability of each approach is an underappreciated factor when choosing an API layer.
tRPC procedures are straightforward to unit test because they are plain TypeScript functions. You call the procedure directly with a mock context — no HTTP setup, no mock server, no supertest. Testing a tRPC procedure that checks user permissions and creates a database record requires mocking ctx.db and ctx.session, nothing more.
REST route handlers in Next.js are harder to test in isolation. The NextRequest and NextResponse types require constructing mock request objects. Integration tests with fetch against a running server are more common than unit tests, which means slower test feedback loops and more test infrastructure overhead.
GraphQL resolvers are easy to unit test when isolated (call the resolver function with a mock context), but end-to-end GraphQL tests require either a mock server or a running GraphQL endpoint. The added complexity of the schema and resolver registration often pushes teams toward integration testing over unit testing.
For boilerplates that ship test suites, tRPC's testability advantage is reflected in the test structure. Supastarter and T3-based starters typically have more comprehensive unit test coverage of their data access layer precisely because tRPC procedures are easy to test directly.
Find boilerplates by API architecture on StarterPick.
Review T3 Stack and compare alternatives on StarterPick.
Read the best Next.js boilerplates guide to see which API layer each top boilerplate chose and how those choices affect the development experience.
Read the best SaaS boilerplates guide for a full ranked comparison where API architecture is one of many factors evaluated.
See our ideal SaaS tech stack guide for where tRPC fits in the full 2026 consensus stack.
Compare ORM choices that pair with your API layer: Drizzle vs Prisma for boilerplates 2026.
See how App Router patterns interact with tRPC and Server Actions: Next.js 15 App Router patterns for SaaS 2026.