REST vs GraphQL vs tRPC: Which One to Choose?
Choosing the communication paradigm between client and server is one of the most important architectural decisions in a modern project. REST, GraphQL, and tRPC represent three radically different approaches, each with its own philosophy, strengths, and trade-offs.
In this article, we will analyze each approach in depth, compare their characteristics in a detailed table, and build a decision framework to help you choose the right one for your next project.
What You Will Learn
- REST fundamentals and Richardson's maturity model
- GraphQL schema, resolvers, and codegen
- tRPC's end-to-end type safety
- Detailed comparison: performance, DX, caching, security
- A decision framework for choosing
- Practical code examples for each approach
REST: The Reliable Veteran
REST (Representational State Transfer) is the most widespread architectural style for web APIs. Defined by Roy Fielding in his doctoral dissertation in 2000, it is based on resources identified by URLs, standard HTTP methods, and stateless representations.
Richardson's Maturity Model
Leonard Richardson defined four maturity levels for REST APIs:
- Level 0 - The Tunnel: A single endpoint that accepts everything (RPC-over-HTTP style)
- Level 1 - Resources: Different endpoints for different resources, but only one HTTP method
- Level 2 - HTTP Verbs: Proper use of GET, POST, PUT, DELETE, and status codes
- Level 3 - HATEOAS: Responses contain links to subsequent actions (hypermedia)
REST Example in TypeScript
import express from 'express';
interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
}
const app = express();
app.use(express.json());
// GET /api/products - Product list with filters
app.get('/api/products', async (req, res) => {
const { category, minPrice, maxPrice, page = '1', limit = '20' } = req.query;
const filters: ProductFilters = {
category: category as string,
minPrice: minPrice ? Number(minPrice) : undefined,
maxPrice: maxPrice ? Number(maxPrice) : undefined
};
const products = await productService.findAll(
filters,
Number(page),
Number(limit)
);
res.json({
data: products.items,
pagination: {
page: products.page,
limit: products.limit,
total: products.total,
totalPages: Math.ceil(products.total / products.limit)
}
});
});
// GET /api/products/:id - Product detail
app.get('/api/products/:id', async (req, res) => {
const product = await productService.findById(req.params.id);
if (!product) {
return res.status(404).json({
error: 'NOT_FOUND',
message: 'Product not found'
});
}
res.json({ data: product });
});
// POST /api/products - Create product
app.post('/api/products', async (req, res) => {
const product = await productService.create(req.body);
res.status(201).json({ data: product });
});
// PUT /api/products/:id - Update product
app.put('/api/products/:id', async (req, res) => {
const product = await productService.update(req.params.id, req.body);
res.json({ data: product });
});
// DELETE /api/products/:id - Delete product
app.delete('/api/products/:id', async (req, res) => {
await productService.delete(req.params.id);
res.status(204).send();
});
@Injectable({ providedIn: 'root' })
export class ProductApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/products';
getProducts(filters?: ProductFilters): Observable<PaginatedResponse<Product>> {
const params = this.buildParams(filters);
return this.http.get<PaginatedResponse<Product>>(this.baseUrl, { params });
}
getProduct(id: string): Observable<Product> {
return this.http.get<Product>(this.baseUrl + '/' + id);
}
createProduct(data: CreateProductDto): Observable<Product> {
return this.http.post<Product>(this.baseUrl, data);
}
updateProduct(id: string, data: UpdateProductDto): Observable<Product> {
return this.http.put<Product>(this.baseUrl + '/' + id, data);
}
deleteProduct(id: string): Observable<void> {
return this.http.delete<void>(this.baseUrl + '/' + id);
}
private buildParams(filters?: ProductFilters): HttpParams {
let params = new HttpParams();
if (filters?.category) params = params.set('category', filters.category);
if (filters?.minPrice) params = params.set('minPrice', filters.minPrice.toString());
if (filters?.maxPrice) params = params.set('maxPrice', filters.maxPrice.toString());
return params;
}
}
GraphQL: Total Flexibility
GraphQL, created by Facebook in 2012 and publicly released in 2015, offers a completely different approach: the client specifies exactly which data it wants to receive. This eliminates the problems of over-fetching (receiving too much data) and under-fetching (needing to make multiple requests to get all necessary data).
Schema and Type System
# schema.graphql
type Product {
id: ID!
name: String!
price: Float!
category: Category!
inStock: Boolean!
reviews: [Review!]!
averageRating: Float
}
type Category {
id: ID!
name: String!
products: [Product!]!
}
type Review {
id: ID!
author: String!
rating: Int!
comment: String
createdAt: String!
}
type PaginatedProducts {
items: [Product!]!
total: Int!
hasNextPage: Boolean!
}
input ProductFilter {
category: String
minPrice: Float
maxPrice: Float
inStock: Boolean
}
type Query {
products(filter: ProductFilter, page: Int, limit: Int): PaginatedProducts!
product(id: ID!): Product
categories: [Category!]!
}
type Mutation {
createProduct(input: CreateProductInput!): Product!
updateProduct(id: ID!, input: UpdateProductInput!): Product!
deleteProduct(id: ID!): Boolean!
}
input CreateProductInput {
name: String!
price: Float!
categoryId: ID!
}
input UpdateProductInput {
name: String
price: Float
categoryId: ID
inStock: Boolean
}
Resolvers and Server
const resolvers = {
Query: {
products: async (_, { filter, page = 1, limit = 20 }, context) => {
const result = await context.productService.findAll(filter, page, limit);
return {
items: result.items,
total: result.total,
hasNextPage: page * limit < result.total
};
},
product: async (_, { id }, context) => {
return context.productService.findById(id);
},
categories: async (_, __, context) => {
return context.categoryService.findAll();
}
},
Product: {
// Resolver for relational fields (loaded only when requested)
reviews: async (product, _, context) => {
return context.reviewService.findByProductId(product.id);
},
averageRating: async (product, _, context) => {
const reviews = await context.reviewService.findByProductId(product.id);
if (reviews.length === 0) return null;
const sum = reviews.reduce((acc, r) => acc + r.rating, 0);
return sum / reviews.length;
},
category: async (product, _, context) => {
return context.categoryService.findById(product.categoryId);
}
},
Mutation: {
createProduct: async (_, { input }, context) => {
return context.productService.create(input);
},
updateProduct: async (_, { id, input }, context) => {
return context.productService.update(id, input);
},
deleteProduct: async (_, { id }, context) => {
await context.productService.delete(id);
return true;
}
}
};
Client Queries
// The client requests EXACTLY the fields it needs
const GET_PRODUCT_LIST = gql`
query GetProducts($filter: ProductFilter, $page: Int) {
products(filter: $filter, page: $page, limit: 20) {
items {
id
name
price
inStock
category {
name
}
}
total
hasNextPage
}
}
`;
// The detail page needs more fields
const GET_PRODUCT_DETAIL = gql`
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price
inStock
category {
id
name
}
reviews {
id
author
rating
comment
createdAt
}
averageRating
}
}
`;
tRPC: End-to-End Type Safety
tRPC is the most recent of the three approaches and represents a paradigm shift: it completely eliminates the serialization layer between client and server when both are written in TypeScript. Thanks to type inference, the client automatically knows the input and output types of every procedure without needing codegen or external schemas.
Router Definition (Server)
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<Context>().create();
// Validation schema with Zod
const productFilterSchema = z.object({
category: z.string().optional(),
minPrice: z.number().min(0).optional(),
maxPrice: z.number().min(0).optional(),
inStock: z.boolean().optional()
});
const createProductSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
categoryId: z.string().uuid()
});
const updateProductSchema = z.object({
name: z.string().min(1).max(200).optional(),
price: z.number().positive().optional(),
categoryId: z.string().uuid().optional(),
inStock: z.boolean().optional()
});
// Router: defines all procedures
export const productRouter = t.router({
list: t.procedure
.input(z.object({
filter: productFilterSchema.optional(),
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(20)
}))
.query(async ({ input, ctx }) => {
const result = await ctx.productService.findAll(
input.filter, input.page, input.limit
);
return {
items: result.items,
total: result.total,
hasNextPage: input.page * input.limit < result.total
};
}),
getById: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const product = await ctx.productService.findById(input.id);
if (!product) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Product not found'
});
}
return product;
}),
create: t.procedure
.input(createProductSchema)
.mutation(async ({ input, ctx }) => {
return ctx.productService.create(input);
}),
update: t.procedure
.input(z.object({
id: z.string().uuid(),
data: updateProductSchema
}))
.mutation(async ({ input, ctx }) => {
return ctx.productService.update(input.id, input.data);
}),
delete: t.procedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
await ctx.productService.delete(input.id);
return { success: true };
})
});
export type ProductRouter = typeof productRouter;
tRPC Client with Full Type Safety
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { ProductRouter } from '../server/routers/product';
// The client knows ALL types automatically
const trpc = createTRPCClient<ProductRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc'
})
]
});
// Full autocompletion and type checking
async function loadProducts() {
// TypeScript knows the result has items, total, hasNextPage
const result = await trpc.product.list.query({
filter: { category: 'electronics', minPrice: 10 },
page: 1,
limit: 20
});
// Compilation error if accessing a non-existent field
// result.items[0].nonExistent // TS Error!
return result;
}
// Mutations are also typed
async function createProduct() {
const newProduct = await trpc.product.create.mutate({
name: 'New Product',
price: 29.99,
categoryId: '550e8400-e29b-41d4-a716-446655440000'
});
// newProduct has the Product type inferred from the server
console.log(newProduct.id);
}
Detailed Comparison
Complete Comparison Table
| Aspect | REST | GraphQL | tRPC |
|---|---|---|---|
| Paradigm | Resource-oriented | Query language | RPC with type inference |
| Type Safety | Manual (OpenAPI/Swagger) | Codegen required | Automatic end-to-end |
| Over/Under-fetching | Common (solved with BFF) | Solved by design | Depends on definition |
| HTTP Caching | Native (ETags, Cache-Control) | Difficult (everything is POST) | Possible for GET queries |
| Learning Curve | Low | Medium-high | Low (if you know TS) |
| Tooling | Mature and vast | Rich (Apollo, Relay) | Rapidly growing |
| Performance | Good, predictable | Variable (N+1 problem) | Excellent (native batching) |
| Errors | HTTP status codes | Always 200 (errors in body) | Typed error codes |
| Versioning | URL or header versioning | Schema evolution deprecation | Router versioning |
| File Upload | Native (multipart) | Complex (separate spec) | Dedicated plugins |
| Real-time | Separate WebSocket / SSE | Native subscriptions | Subscriptions via WebSocket |
| Non-TS Clients | Any language | Any language | TypeScript only |
| Maturity | 20+ years, de facto standard | 10+ years, widely adopted | 3+ years, rapidly growing |
Decision Framework
Choosing between REST, GraphQL, and tRPC is not absolute: it depends on the project context. Here is a framework to guide the decision.
Choose REST when:
- The API must be consumed by clients in different languages (mobile, third parties)
- Native HTTP caching is critical for performance
- The team has established experience with REST
- The API is public and must follow universally understood standards
- Resources are well-defined and queries are predictable
- A mature ecosystem with extensive documentation is needed
Choose GraphQL when:
- Many different clients have different data needs (web, mobile, TV)
- Entity relationships are complex and deeply nested
- Over-fetching is a real performance problem (limited bandwidth)
- The team is willing to invest in the learning curve
- A subscription system for real-time data is needed
- The API aggregates data from multiple microservices (GraphQL Federation)
Choose tRPC when:
- Client and server are both in TypeScript (monorepo ideal)
- End-to-end type safety is an absolute priority
- The team wants maximum development speed with autocompletion
- There are no non-TypeScript clients to support
- The project is a full-stack application (Next.js, Nuxt, SvelteKit)
- You want to eliminate codegen boilerplate and manual contracts
Security: Considerations for Each Approach
REST
- Authentication: Standard Authorization header (Bearer token, API key)
- Rate limiting: Per endpoint, simple to implement
- Validation: Server-side responsibility (middleware like Joi, Zod)
- CORS: Standard and well-documented configuration
GraphQL
- Query depth limiting: Necessary to prevent malicious recursive queries
- Query cost analysis: Estimate computational cost before execution
- Introspection: Disable in production to avoid exposing the schema
- Persisted queries: Only pre-approved queries in production
tRPC
- Built-in validation: Zod automatically validates every input
- Middleware: Middleware chain for auth, logging, rate limiting
- Type safety: Many errors are caught at compile-time
- Batching: Be careful with timeout and batch size
The Hybrid Approach
In many real-world projects, the best solution is a hybrid approach. For example:
- tRPC for the internal frontend (admin dashboard, company portal) where client and server are both TypeScript
- REST for public APIs intended for third parties and mobile clients
- GraphQL as a gateway to aggregate data from multiple microservices
+------------------+
| Web App (TS) |--- tRPC ---+
+------------------+ |
v
+------------------+ +---------------+ +----------------+
| Mobile App | | API Gateway | | Microservices |
| (Swift/Kotlin) |--->| (REST/GQL) |--->| (gRPC/REST) |
+------------------+ +---------------+ +----------------+
^
+------------------+ |
| Partner APIs |--- REST ---+
+------------------+
Conclusion
There is no absolute winner among REST, GraphQL, and tRPC. Each approach excels in specific scenarios:
Related deep dive: 6 - Testing Architecture: Strategie per App Angular.
- REST remains the safest choice for public APIs, interoperability, and when HTTP caching is critical
- GraphQL shines when data is complex, clients are heterogeneous, and query flexibility is essential
- tRPC is unbeatable for developer experience in full-stack TypeScript projects
Key Takeaways
- Context first: The choice depends on the project, not the trending technology
- Type safety: tRPC offers the best experience, but requires TypeScript everywhere
- Flexibility: GraphQL solves over/under-fetching but adds complexity
- Standards: REST is universally understood and supported
- Hybrid: In large projects, combining multiple approaches is often the best choice
Frequently asked questions
What is the main difference between GraphQL and REST?
GraphQL allows clients to specify exactly which data they want to receive, eliminating over-fetching and under-fetching issues, whereas REST relies on standard HTTP methods and stateless representations.
What is tRPC's key feature compared to other approaches?
tRPC eliminates the serialization layer between client and server when both are written in TypeScript, providing end-to-end type safety through type inference.
How does Richardson's Maturity Model relate to REST APIs?
Richardson defined four maturity levels for REST APIs, which can be used as a framework to evaluate and improve the design of REST-based systems.
What is often the best solution in real-world projects according to the article?
A hybrid approach combining elements from different paradigms, such as using tRPC for certain parts and another approach for others.
Commenti
Caricamento commenti...
Sign in to comment