
REST API vs GraphQL vs tRPC in 2026: Which One Should You Use?
π· Stanislav Kondratiev / PexelsREST API vs GraphQL vs tRPC in 2026: Which One Should You Use?
A detailed comparison of REST, GraphQL, and tRPC for modern web development. Learn the pros, cons, performance characteristics, and best use cases for each approach.
Choosing how your frontend communicates with your backend is one of the most consequential architectural decisions in a web project. In 2026, developers have three mature, battle-tested options: REST, GraphQL, and tRPC. Each has distinct strengths, trade-offs, and ideal use cases. This article provides a thorough comparison to help you make the right choice for your next project.
REST API: The Established Standard
REST (Representational State Transfer) has been the dominant API paradigm for over two decades. It maps HTTP methods to CRUD operations on resources, making it intuitive and well-understood by virtually every developer.
How REST Works
REST APIs expose resources as URLs. Clients interact with them using standard HTTP methods:
// GET a list of users
const response = await fetch('https://api.example.com/users');
const users = await response.json();
// GET a single user
const user = await fetch('https://api.example.com/users/42');
// POST to create a new user
const newUser = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Alice Johnson',
email: 'alice@example.com',
}),
});
// PUT to update a user
await fetch('https://api.example.com/users/42', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice Smith' }),
});
// DELETE a user
await fetch('https://api.example.com/users/42', {
method: 'DELETE',
});
Server-Side REST Example (Express)
import express from 'express';
const app = express();
app.use(express.json());
// GET /users
app.get('/users', async (req, res) => {
const { page = 1, limit = 20, sort = 'name' } = req.query;
const users = await db.users.findMany({
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
orderBy: { [sort as string]: 'asc' },
});
res.json({ data: users, page: Number(page), limit: Number(limit) });
});
// GET /users/:id
app.get('/users/:id', async (req, res) => {
const user = await db.users.findUnique({
where: { id: Number(req.params.id) },
include: { posts: true, profile: true },
});
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST /users
app.post('/users', async (req, res) => {
const user = await db.users.create({ data: req.body });
res.status(201).json(user);
});
Strengths of REST
- Universally understood: Every developer, every language, every platform supports REST.
- HTTP caching: GET requests can be cached by browsers, CDNs, and proxies out of the box.
- Stateless and simple: Each request contains all the information the server needs.
- Mature tooling: Swagger/OpenAPI, Postman, and countless libraries.
- Status codes are meaningful: 200, 201, 404, 422, 500 all convey specific information.
Weaknesses of REST
- Over-fetching: An endpoint returns all fields, even if the client only needs a few.
- Under-fetching: Getting related data often requires multiple round trips.
- No built-in type safety: Without additional tooling like OpenAPI code generation, there is no contract between client and server.
- Versioning challenges: Breaking changes often require
/v2/endpoints or header-based versioning.
GraphQL: Flexible Queries
GraphQL, created by Facebook and open-sourced in 2015, lets the client specify exactly what data it needs in a single request. It uses a typed schema to define the API surface.
How GraphQL Works
// A GraphQL query
const query = `
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts(first: 5) {
title
createdAt
}
followers {
totalCount
}
}
}
`;
const response = await fetch('https://api.example.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
variables: { id: '42' },
}),
});
const { data } = await response.json();
// data.user.name, data.user.posts, data.user.followers.totalCount
Server-Side GraphQL Schema
// schema.ts (using a schema-first approach)
const typeDefs = `
type User {
id: ID!
name: String!
email: String!
posts(first: Int, after: String): PostConnection!
followers: FollowerCount!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
createdAt: DateTime!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type Query {
user(id: ID!): User
users(first: Int, after: String): UserConnection!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
}
`;
Resolvers
const resolvers = {
Query: {
user: async (_: unknown, { id }: { id: string }, ctx: Context) => {
return ctx.db.users.findUnique({ where: { id } });
},
users: async (_: unknown, args: PaginationArgs, ctx: Context) => {
return paginateUsers(ctx.db, args);
},
},
User: {
posts: async (parent: User, args: PaginationArgs, ctx: Context) => {
return ctx.loaders.userPosts.load({ userId: parent.id, ...args });
},
followers: async (parent: User, _: unknown, ctx: Context) => {
const count = await ctx.db.followers.count({
where: { followeeId: parent.id },
});
return { totalCount: count };
},
},
Mutation: {
createUser: async (_: unknown, { input }: { input: CreateUserInput }, ctx: Context) => {
return ctx.db.users.create({ data: input });
},
},
};
Strengths of GraphQL
- No over-fetching or under-fetching: Clients request exactly the fields they need.
- Single endpoint: All operations go through one URL.
- Strongly typed schema: The schema serves as living documentation and enables powerful tooling.
- Introspection: Clients can discover the entire API surface programmatically.
- Subscriptions: Built-in support for real-time data via WebSockets.
- Great for diverse clients: A mobile app can request minimal fields while a desktop app fetches everything.
Weaknesses of GraphQL
- Complexity: Resolvers, data loaders, persisted queries, and schema management add cognitive overhead.
- Caching is harder: Since all requests are POSTs to a single endpoint, traditional HTTP caching does not work. You need client-side caching (Apollo, urql) or persisted queries.
- N+1 query problem: Without data loaders, nested resolvers can trigger excessive database queries.
- File uploads: Not natively supported; requires multipart request specs or separate upload endpoints.
- Security surface: Deeply nested or expensive queries can cause denial-of-service unless you implement query depth limiting and complexity analysis.
tRPC: End-to-End Type Safety
tRPC is a TypeScript-specific framework that lets you call server functions directly from the client with full type safety and zero code generation. It is designed for monorepo projects where both the frontend and backend are written in TypeScript.
How tRPC Works
tRPC eliminates the API layer as a separate concept. You define procedures on the server, and the client calls them as if they were local functions.
Server-side router definition:
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.users.findUnique({
where: { id: input.id },
include: { posts: true, profile: true },
});
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user;
}),
list: publicProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().min(1).max(100).default(20),
search: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
const { page, limit, search } = input;
const where = search
? { name: { contains: search, mode: 'insensitive' as const } }
: {};
const [users, total] = await Promise.all([
ctx.db.users.findMany({
where,
skip: (page - 1) * limit,
take: limit,
}),
ctx.db.users.count({ where }),
]);
return { users, total, page, limit };
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.users.create({ data: input });
}),
update: protectedProcedure
.input(z.object({
id: z.string(),
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
return ctx.db.users.update({ where: { id }, data });
}),
});
Client-side usage (with React):
// Client: full type safety, no code generation needed
import { trpc } from '../utils/trpc';
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = trpc.user.getById.useQuery({ id: userId });
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.user.getById.invalidate({ id: userId });
},
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error.message} />;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<button onClick={() => updateUser.mutate({ id: userId, name: 'New Name' })}>
Update Name
</button>
</div>
);
}
Every input, output, and error is fully typed. If you change a field name on the server, the client will show a TypeScript error instantly.
Strengths of tRPC
- End-to-end type safety: Changes on the server are immediately reflected in client types with zero code generation.
- Minimal boilerplate: No schema files, no API route definitions, no client generation step.
- Excellent developer experience: Autocompletion, inline documentation, and refactoring support in your IDE.
- Input validation built in: Zod schemas validate inputs at runtime and provide types at compile time.
- React Query integration: First-class support for caching, optimistic updates, and real-time refetching.
- Subscriptions: WebSocket support for real-time features.
Weaknesses of tRPC
- TypeScript only: Both the client and server must be TypeScript. It does not work with Python, Go, or other backend languages.
- Monorepo-centric: Works best when client and server share a codebase. Less practical when teams work in separate repositories.
- No public API use case: Not suitable for APIs consumed by third parties since there is no language-agnostic schema.
- Coupled client and server: Tight coupling can become a maintenance concern as teams grow.
- Smaller ecosystem: Fewer tutorials, tools, and community resources compared to REST or GraphQL.
Comparison Table
| Feature | REST | GraphQL | tRPC |
|---|---|---|---|
| Type Safety | Manual (OpenAPI codegen) | Schema-based | Automatic (TypeScript) |
| Over-fetching | Common | Eliminated | Minimal (typed returns) |
| Under-fetching | Common (N+1 requests) | Eliminated | Minimal |
| Caching | Excellent (HTTP native) | Complex (client-side) | React Query built-in |
| Learning Curve | Low | Medium-High | Low (if you know TS) |
| Tooling Maturity | Excellent | Very Good | Good |
| Language Support | Any | Any | TypeScript only |
| Real-time | WebSockets/SSE (separate) | Subscriptions | Subscriptions |
| File Uploads | Native | Workaround needed | Workaround needed |
| Public APIs | Ideal | Good | Not suitable |
| Code Generation | Optional (OpenAPI) | Often needed | None needed |
| Performance | Good | Good (with batching) | Excellent (minimal overhead) |
| API Documentation | Swagger/OpenAPI | Schema introspection | TypeScript types |
When to Use Each
Choose REST When
- You are building a public API consumed by third parties in various languages.
- You need HTTP caching at the CDN or browser level (e.g., a content-heavy site).
- Your team includes developers who are more familiar with traditional HTTP APIs.
- You are building microservices that communicate with each other across different language runtimes.
- You want the simplest possible architecture for a straightforward CRUD application.
Choose GraphQL When
- You have multiple clients (web, mobile, TV, watch) that need different data shapes from the same API.
- Your data is deeply relational and clients frequently need nested data in a single request.
- You need real-time features (subscriptions) tightly integrated with your query layer.
- You want a self-documenting API with introspection for developer tooling.
- You are building a gateway that aggregates data from multiple backend services.
Choose tRPC When
- Your entire stack is TypeScript (both frontend and backend).
- You work in a monorepo where client and server code live together.
- You value developer velocity and want the fastest possible iteration speed.
- You are building an internal tool or SaaS product where the API is not exposed publicly.
- Type safety is a top priority and you want to catch API mismatches at compile time.
Real-World Architecture Patterns
Hybrid Approach: tRPC + REST
Many teams use tRPC for their internal frontend-to-backend communication and expose a separate REST API for third-party integrations:
// Internal tRPC router for the dashboard
export const appRouter = router({
user: userRouter,
billing: billingRouter,
analytics: analyticsRouter,
});
// Public REST API for integrations
app.get('/api/v1/users/:id', async (req, res) => {
const user = await db.users.findUnique({
where: { id: req.params.id },
select: publicUserFields,
});
res.json(user);
});
GraphQL as a Gateway
GraphQL works well as an API gateway that federates data from multiple microservices:
// Gateway schema that combines multiple services
const gatewaySchema = stitchSchemas({
subschemas: [
{ schema: userServiceSchema, executor: userServiceExecutor },
{ schema: orderServiceSchema, executor: orderServiceExecutor },
{ schema: inventoryServiceSchema, executor: inventoryServiceExecutor },
],
});
REST with OpenAPI for Type Safety
If you choose REST but want type safety, OpenAPI code generation bridges the gap:
# openapi.yaml
paths:
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
# Generate typed client
npx openapi-typescript-codegen --input openapi.yaml --output src/api
// Generated client with full type safety
import { UserService } from './api';
const user = await UserService.getUser({ id: '42' });
// user is fully typed
Performance Considerations
REST Performance
REST benefits from HTTP/2 multiplexing and browser caching. For read-heavy APIs serving cacheable content, REST can be extremely fast. The downside is multiple round trips when you need data from several endpoints.
GraphQL Performance
GraphQL reduces round trips by allowing complex queries in a single request. However, the server must resolve potentially complex query trees. Without proper optimization (data loaders, query complexity limits, persisted queries), GraphQL can actually be slower than REST.
tRPC Performance
tRPC has minimal serialization overhead since it communicates using simple JSON over HTTP. With request batching enabled, multiple procedure calls are combined into a single HTTP request, reducing round trips. The React Query integration provides client-side caching that further reduces network requests.
Conclusion
There is no single best API approach. The right choice depends on your team, your technology stack, and your project requirements.
REST remains the default choice for public APIs, microservice communication, and teams working across multiple languages. Its simplicity and universal support make it the safe choice for most scenarios.
GraphQL excels when you have complex, relational data and multiple client applications that need different views of that data. It adds complexity, but the flexibility it provides can be worth it for the right use case.
tRPC is the best option for TypeScript monorepo projects where developer velocity and type safety are priorities. It removes an entire category of bugs (API contract mismatches) and provides the fastest development experience of the three.
Many successful teams use a combination of these approaches, leveraging each where it makes the most sense. The important thing is to understand the trade-offs and make an intentional decision rather than defaulting to whatever your last project used.