TL;DR: If you own both the client and server in TypeScript, use tRPC — you get end-to-end type safety with zero code generation. If you’re building a public API or serving multiple non-TypeScript clients, use REST with OpenAPI. GraphQL is the right choice when your frontend team needs flexible data fetching across a complex, multi-entity data graph. Don’t pick based on hype; pick based on your team topology.
Every new project starts with the same debate: how should the frontend talk to the backend?
The answer depends on exactly one thing: who consumes your API? If you answer that honestly, the choice becomes obvious.
Table of Contents
- The Decision Framework
- REST: The Universal Default
- tRPC: Type Safety Without the Ceremony
- GraphQL: The Flexible Query Language
- The Comparison
- My Recommendation
The Decision Framework
Before looking at each option, answer these questions:
- Do you own both client and server? (Or is the API consumed by third parties?)
- Is everything TypeScript? (Or do you have mobile clients in Swift/Kotlin?)
- How complex is your data graph? (Simple CRUD or deeply nested relationships?)
- How big is your team? (Solo dev or separate frontend/backend teams?)
These four questions eliminate 90% of the debate.
REST: The Universal Default
REST isn’t exciting, and that’s its advantage. Every developer knows it, every tool supports it, every tutorial assumes it.
When REST Wins
- Public APIs — External consumers expect REST. They have HTTP clients in every language. They don’t want to install your SDK.
- Simple CRUD — If your API is mostly “get thing, create thing, update thing, delete thing,” REST maps perfectly.
- Multi-platform clients — iOS, Android, web, CLI tools. REST is the lowest common denominator in the best way.
The Real Pattern
Modern REST isn’t the “pure REST” from academic papers. Nobody implements HATEOAS. The practical pattern in 2026 is:
// Server — Express/Hono/Fastify
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
return res.json(user);
});
// Client — fetch with type assertion
const res = await fetch(`/api/users/${id}`);
const user: User = await res.json(); // ← the type gap
That User type assertion is the problem. The client hopes the server returns a User. If the server changes the response shape, the client breaks at runtime, not compile time.
You can close this gap with OpenAPI:
# openapi.yaml
paths:
/api/users/{id}:
get:
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
Then generate client types with openapi-typescript:
npx openapi-typescript ./openapi.yaml -o ./src/api/types.ts
This works. It’s also a maintenance burden — you’re hand-writing YAML that must stay in sync with your server code. For public APIs, this cost is worth it. For internal APIs, there’s a better way.
tRPC: Type Safety Without the Ceremony
tRPC eliminates the API layer entirely. There’s no schema file, no code generation, no HTTP method debates. The server defines procedures, and the client calls them with full TypeScript inference.
When tRPC Wins
- Full-stack TypeScript — You own both client and server, and both are TypeScript
- Solo devs or small teams — No coordination overhead between frontend and backend
- Rapid iteration — Change a server return type and see red squiggles in the client instantly
How It Works
// Server — define your router
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
const user = await db.users.findById(input.id);
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user; // Return type is inferred
}),
createUser: t.procedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
return await db.users.create(input);
}),
});
export type AppRouter = typeof appRouter;
// Client — full inference, zero codegen
import { createTRPCClient } from '@trpc/client';
import type { AppRouter } from '../server/router';
const trpc = createTRPCClient<AppRouter>({
/* config */
});
const user = await trpc.getUser.query({ id: '123' });
// user is fully typed — inferred from the server return type
// Hover over `user` in your IDE and see the exact shape
const newUser = await trpc.createUser.mutate({
name: 'Jordan',
email: 'not-an-email', // ❌ Zod validation catches this at runtime
});
The magic: AppRouter is just a type import. No server code is bundled into the client. TypeScript’s type system connects the two sides at compile time and erases at runtime.
The Limitation
tRPC requires TypeScript on both ends. If you ever need to serve a mobile app in Swift, a Python script, or a third-party integration, tRPC can’t help them. You’d need to add a REST layer alongside it.
GraphQL: The Flexible Query Language
GraphQL lets the client request exactly the data it needs. No over-fetching, no under-fetching. The server defines a schema, and clients write queries against it.
When GraphQL Wins
- Complex data graphs — Entities with deep relationships (users → posts → comments → authors)
- Multiple frontend surfaces — A mobile app needs 3 fields, a web dashboard needs 20, and they hit the same API
- Separate frontend/backend teams — The schema acts as a contract; teams can work independently
The Real Cost
GraphQL’s flexibility comes at a price:
- Schema duplication — You define types in the GraphQL schema AND in TypeScript. Code generators (graphql-codegen) help but add tooling complexity.
- N+1 queries — Without a dataloader, a nested query like
users { posts { comments } }fires one query per user per post. DataLoader solves this but requires explicit implementation. - Caching complexity — REST responses cache by URL. GraphQL POST requests with unique query bodies don’t cache at the HTTP level. You need a client-side cache (Apollo, urql) or persisted queries.
// The query
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts(limit: 5) {
title
commentCount
}
}
}
`;
// Generated type (via graphql-codegen)
type GetUserQuery = {
user: {
name: string;
email: string;
posts: Array<{ title: string; commentCount: number }>;
} | null;
};
This is powerful for complex UIs. It’s overkill for a blog or portfolio site.
The Comparison
| REST + OpenAPI | tRPC | GraphQL | |
|---|---|---|---|
| Type safety | Via codegen (manual sync) | Automatic (inferred) | Via codegen (schema-first) |
| Setup effort | Low | Low | Medium-High |
| Client flexibility | Fixed endpoints | Fixed procedures | Flexible queries |
| Multi-language clients | ✅ Universal | ❌ TypeScript only | ✅ Universal |
| Caching | HTTP caching (free) | Query-level (manual) | Complex (client-side) |
| Best team size | Any | Solo → small | Medium → large |
| Learning curve | None | Low | Moderate |
My Recommendation
For most projects in 2026:
Solo dev, full-stack TypeScript? → tRPC. No contest. You get end-to-end type safety with the least tooling overhead. The DX is unmatched.
Building a public API? → REST with OpenAPI. Generate client SDKs for consumers. It’s boring, universal, and cacheable.
Complex data graph, multiple teams? → GraphQL. The schema-as-contract model shines when frontend and backend teams need to iterate independently on a rich data model.
Starting and not sure? → Start with tRPC if you’re in TypeScript. It’s the easiest to add and the easiest to replace. If you later need a public API, add REST routes alongside it. You’re not locked in — they coexist cleanly.
Don’t pick an API strategy because a blog post told you it’s “the future.” Pick it because it solves the specific coordination problem your team has today.