REST vs GraphQL vs tRPC — Complete API Comparison 2026
Advertisement
Introduction
Why This Matters
Choosing the wrong API paradigm creates years of technical debt. REST, GraphQL, and tRPC each solve different problems — and picking the right one depends on your team size, client diversity, and type-safety requirements. In 2026, tRPC has matured for monorepos, GraphQL dominates multi-client platforms, and REST remains the safe default for public APIs.
REST — The Standard Approach
REST uses HTTP methods and URLs to model resources. It is stateless, cacheable, and supported by every HTTP client on earth.
// Express REST API with full TypeScript types
import express, { Request, Response } from 'express';
const router = express.Router();
interface User {
id: number;
email: string;
name: string;
}
// GET /users/:id
router.get('/users/:id', async (req: Request, res: Response) => {
const userId = parseInt(req.params.id, 10);
const user = await db.users.findById(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
return res.status(200).json(user);
});
// POST /users
router.post('/users', async (req: Request, res: Response) => {
const { email, name } = req.body as Pick<User, 'email' | 'name'>;
const user = await db.users.create({ email, name });
return res.status(201).json(user);
});REST strengths: HTTP caching works out of the box, every tool in existence understands it, and debugging is trivial with curl or Postman. REST weaknesses: over-fetching (getting 30 fields when you need 3) and under-fetching (needing N+1 requests for related data).
GraphQL — Flexible Queries for Multiple Clients
GraphQL lets clients request exactly the data they need. One endpoint, one request, zero over-fetching.
// Apollo Server v4 with TypeScript
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const typeDefs = `
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(email: String!, name: String!): User!
}
`;
const resolvers = {
Query: {
user: async (_: unknown, { id }: { id: string }) => {
return db.users.findById(id);
},
users: async () => db.users.findAll(),
},
User: {
posts: async (parent: { id: string }) => {
return db.posts.findByUserId(parent.id);
},
},
Mutation: {
createUser: async (_: unknown, { email, name }: { email: string; name: string }) => {
return db.users.create({ email, name });
},
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });GraphQL strengths: clients define their own data shape, perfect for mobile apps with bandwidth constraints, and a single endpoint for all operations. GraphQL weaknesses: complex caching, N+1 query problem (solved with DataLoader), and higher learning curve.
tRPC — End-to-End Type Safety Without Schema Files
tRPC generates TypeScript types automatically from your server router, eliminating the need for code generation or schema files. It only works in TypeScript monorepos where client and server share code.
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
user: t.router({
getById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.users.findById(input.id);
}),
create: t.procedure
.input(z.object({
email: z.string().email(),
name: z.string().min(1),
}))
.mutation(async ({ input }) => {
return db.users.create(input);
}),
}),
});
export type AppRouter = typeof appRouter;
// client/api.ts — 100% type-safe, no code generation
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000/api/trpc' })],
});
// Full autocomplete and type inference
const user = await trpc.user.getById.query({ id: '123' });
const newUser = await trpc.user.create.mutate({ email: 'user@example.com', name: 'Alice' });tRPC strengths: zero runtime overhead for types, instant refactoring across client and server, and Zod validation built in. tRPC weaknesses: TypeScript-only, requires monorepo or shared package, and not suitable for third-party API consumers.
Side-by-Side Comparison
| Criterion | REST | GraphQL | tRPC |
|---|---|---|---|
| Type safety | Manual / OpenAPI | Code-gen from schema | Automatic inference |
| Over-fetching | Yes | No | No |
| Caching | HTTP native | Manual / persisted queries | React Query / SWR |
| Language support | Any | Any | TypeScript only |
| Public API | Ideal | Possible | Not suitable |
| Setup complexity | Low | Medium | Low (monorepo) |
| Client flexibility | Fixed endpoints | Full control | Fixed procedures |
| Bundle impact | None | Apollo is large | Minimal |
When to Use Each
Use REST when you are building a public API consumed by third-party developers, need HTTP caching at the CDN level, or your team is not TypeScript-heavy.
Use GraphQL when multiple client types (web, iOS, Android) consume the same backend but need different data shapes, or you have a complex graph of related data.
Use tRPC when you are building a full-stack TypeScript application in a monorepo (e.g., Next.js + Node), want instant type safety without code generation, and your API is never consumed outside your own codebase.
Common Mistakes
Mistake 1 — Using GraphQL for a simple CRUD app: GraphQL overhead is not worth it for straightforward data access without multiple clients.
Mistake 2 — Using tRPC for a public API: tRPC procedures are not self-documenting and cannot be called from non-TypeScript clients.
Mistake 3 — Ignoring the N+1 problem in GraphQL: every nested resolver that hits the database without DataLoader will destroy performance at scale.
// DataLoader pattern to batch and cache DB calls
import DataLoader from 'dataloader';
const userLoader = new DataLoader(async (ids: readonly string[]) => {
const users = await db.users.findManyByIds([...ids]);
return ids.map((id) => users.find((u) => u.id === id) ?? null);
});
// Now resolvers use the loader, not direct DB calls
const resolvers = {
Post: {
author: (post: { authorId: string }) => userLoader.load(post.authorId),
},
};Best Practices
- Validate all inputs server-side regardless of API layer: use Zod with tRPC, input types with GraphQL, or express-validator with REST.
- Version your REST APIs (
/v1/,/v2/) before they are consumed externally — GraphQL and tRPC evolve the schema in place. - Use persisted queries in GraphQL to prevent arbitrary query abuse and enable CDN caching.
- Keep tRPC routers modular: one router file per domain (users, posts, billing) and merge them at the root.
- Set response timeouts on all three: a slow resolver will block a thread without a timeout guard.
Key Takeaways
- REST is the right default for public-facing APIs because it is universally supported and HTTP-cacheable.
- GraphQL eliminates over-fetching by letting clients specify exactly which fields they need in a single request.
- tRPC provides end-to-end TypeScript type safety with zero code generation by sharing router types between client and server.
- tRPC is TypeScript-monorepo-only and should never be used as a public API.
- The N+1 query problem is GraphQL's most common performance trap — always use DataLoader for nested resolver DB calls.
- GraphQL requires explicit caching strategies because HTTP-level caching does not work with POST-based queries.
- All three patterns can coexist: public REST endpoints, internal GraphQL for the product, and tRPC for admin tooling.
- Input validation with Zod is a best practice for all three paradigms, not just tRPC.
Advertisement