Your API style is not just a technical decision — it determines how your team thinks about data, how your clients evolve independently of your server, and how much friction exists between backend changes and frontend code.
REST has been the default for 15 years because it maps cleanly to HTTP semantics and works with every client that can make an HTTP request. GraphQL emerged to solve specific problems REST creates at scale: over-fetching (the server sends more data than the client needs), under-fetching (the client needs multiple roundtrips to get related data), and the proliferation of bespoke endpoints for each UI view. tRPC sidesteps both by leaning into TypeScript — no schema language, no code generation, the types from your server functions become your client types.
Each approach makes different tradeoffs. None is universally correct.
The Same Resource, Three Ways
A user profile endpoint that returns a user with their recent posts:
REST
// GET /users/:id
// GET /users/:id/posts?limit=5
// Server
app.get('/users/:id', authenticate, async (req, res) => {
const user = await db.user.findUnique({
where: { id: req.params.id },
select: { id: true, name: true, email: true, bio: true, avatarUrl: true, createdAt: true }
})
if (!user) return res.status(404).json({ error: 'User not found' })
res.json(user)
})
app.get('/users/:id/posts', authenticate, async (req, res) => {
const posts = await db.post.findMany({
where: { authorId: req.params.id, published: true },
take: Number(req.query.limit) || 5,
orderBy: { createdAt: 'desc' },
select: { id: true, title: true, slug: true, createdAt: true }
})
res.json(posts)
})
// Client — two round trips
const user = await fetch(`/api/users/${userId}`).then(r => r.json())
const posts = await fetch(`/api/users/${userId}/posts?limit=5`).then(r => r.json())
// TypeScript types maintained manually or via OpenAPI codegenGraphQL
// Server — Apollo Server or Pothos
import { createYoga, createSchema } from 'graphql-yoga'
import { builder } from './builder' // Pothos schema builder
builder.queryField('user', t =>
t.field({
type: User,
args: { id: t.arg.string({ required: true }) },
resolve: async (_, { id }, ctx) => {
const user = await ctx.db.user.findUnique({ where: { id } })
if (!user) throw new GraphQLError('User not found', { extensions: { code: 'NOT_FOUND' } })
return user
}
})
)
builder.objectType(User, {
fields: t => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
bio: t.exposeString('bio', { nullable: true }),
recentPosts: t.field({
type: [Post],
args: { limit: t.arg.int({ defaultValue: 5 }) },
resolve: (user, { limit }, ctx) =>
ctx.db.post.findMany({
where: { authorId: user.id, published: true },
take: limit,
orderBy: { createdAt: 'desc' }
})
})
})
})
// Client — one query, client controls the shape
const query = `
query GetUserProfile($id: ID!, $postLimit: Int) {
user(id: $id) {
id
name
bio
recentPosts(limit: $postLimit) {
id
title
slug
}
}
}
`
const { data } = await client.query({ query, variables: { id: userId, postLimit: 5 } })
// data.user is typed (with codegen) — no extra fields includedtRPC
// Server
const usersRouter = router({
getProfile: publicProcedure
.input(z.object({
id: z.string().cuid(),
postLimit: z.number().int().min(1).max(20).default(5)
}))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } })
if (!user) throw new TRPCError({ code: 'NOT_FOUND' })
const posts = await db.post.findMany({
where: { authorId: input.id, published: true },
take: input.postLimit,
orderBy: { createdAt: 'desc' },
select: { id: true, title: true, slug: true, createdAt: true }
})
return { user, posts }
})
})
// Client — typed, no query language, no codegen
const { data } = trpc.users.getProfile.useQuery({ id: userId, postLimit: 5 })
// data.user and data.posts are fully typed from the server function's return typeAll three solve the problem. The code volume is similar at this scale. The differences emerge as the API grows.
Type Safety: The Spectrum
This is the most practical difference for TypeScript teams.
REST: Types Are Your Responsibility
// You define and maintain types manually, or use OpenAPI codegen
interface User {
id: string
name: string
email: string
bio: string | null
createdAt: string
}
// openapi-typescript generates types from your OpenAPI spec
// npx openapi-typescript openapi.yaml -o types.ts
import type { paths } from './types'
type GetUserResponse = paths['/users/{id}']['get']['responses']['200']['content']['application/json']
// Breaks if the server changes and you don't regenerateGraphQL: Types From Schema (With Codegen)
// graphql-codegen generates TypeScript types from your schema
// codegen.ts
const config = {
schema: 'http://localhost:4000/graphql',
documents: ['src/**/*.graphql'],
generates: {
'src/__generated__/graphql.ts': {
plugins: ['typescript', 'typescript-operations', 'typescript-react-apollo']
}
}
}
// Generated types are accurate — if schema changes, codegen fails
// Query types reflect exactly what the query requests, not the full schema type
type GetUserProfileQuery = {
user: {
id: string
name: string
bio: string | null
recentPosts: Array<{ id: string; title: string; slug: string }>
}
}The codegen step is the friction point. Types are only accurate after you run the generator, and the generator must match the current schema. In practice this means a codegen step in CI and discipline around keeping it current.
tRPC: Types Without a Schema or Generator
// No schema language. No codegen step. No types to maintain.
// The TypeScript type of your router IS the client contract.
export type AppRouter = typeof appRouter
// On the client:
import type { AppRouter } from '../server/router'
import { createTRPCReact } from '@trpc/react-query'
const trpc = createTRPCReact<AppRouter>()
// trpc.users.getProfile.useQuery({ id }) is typed at the call site
// If the server changes the return type, the TypeScript compiler fails in the client immediately
// No regeneration, no sync step, no drifttRPC's type safety is the strictest and the most frictionless — but it only works for TypeScript clients. A mobile app in Swift or an Android app in Kotlin can't consume a tRPC API without wrapping it in a REST or RPC adapter.
Over-fetching and Under-fetching
REST: Both Problems Exist
REST endpoints return fixed shapes. A /users/:id endpoint that returns { id, name, email, bio, avatarUrl, lastLoginAt, settings, ...20 more fields } sends all of it regardless of whether the caller needs one field or all of them.
The traditional solution — create endpoint variants like /users/:id/summary — creates endpoint proliferation. Each frontend view gets its own endpoint, and the backend becomes a set of bespoke data loaders for specific UIs.
GraphQL: Solved by Design
# Profile page — needs everything
query ProfilePage($id: ID!) {
user(id: $id) {
name, bio, avatarUrl, followerCount
recentPosts(limit: 5) { title, slug, createdAt }
}
}
# Comment list — only needs the author's name and avatar
query CommentList($postId: ID!) {
comments(postId: $postId) {
id, content, createdAt
author { name, avatarUrl } # only these two fields, not the whole user
}
}Each client asks for exactly what it needs. The server resolves only those fields. This is GraphQL's core value proposition — it's most meaningful when you have multiple clients (web, mobile, third-party) with different data requirements from the same data model.
tRPC: Controlled by Procedure Design
// tRPC doesn't solve over-fetching at the query level — the procedure returns what it returns
// You control it through procedure design
const postsRouter = router({
// Returns full post for the post page
getById: publicProcedure.input(z.object({ id: z.string() }))
.query(async ({ input }) => db.post.findUnique({ where: { id: input.id } })),
// Returns summary fields for listing
listSummaries: publicProcedure.input(z.object({ limit: z.number().default(20) }))
.query(async ({ input }) => db.post.findMany({
take: input.limit,
select: { id: true, title: true, slug: true, createdAt: true } // no body field
})),
})tRPC doesn't have field-level selection — you design procedures for specific use cases. This is fine for TypeScript-only backends serving one frontend, but scales less cleanly when multiple clients with different needs share the same API.
Caching
REST: HTTP Cache is Native
// REST responses cache at every layer — browser, CDN, reverse proxy
res.set({
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
'ETag': etag(JSON.stringify(user))
})
// CDN (Cloudflare, Fastly) can cache GET responses automatically
// Browser caches GET responses via HTTP headers
// Varnish/nginx can cache at the reverse proxy levelREST's alignment with HTTP semantics means caching is built into the infrastructure that already exists. A CDN in front of your REST API can serve millions of requests for the same user profile without the request reaching your server.
GraphQL: HTTP Caching Is Harder
GraphQL typically uses POST requests (even for reads), which HTTP caches don't cache by default. Solutions exist — GET requests for queries with the query in URL params, persisted queries, Apollo's response caching — but they require explicit configuration.
At the application level, Apollo Client and urql provide sophisticated normalized caches that are more capable than HTTP caching. At the CDN level, GraphQL requires extra work.
tRPC: React Query Manages Caching
tRPC's client is built on React Query. Caching is managed in the React Query cache — sophisticated (deduplication, background refetch, stale-while-revalidate), but client-side only. CDN caching for tRPC requires wrapping procedures as GET requests with query params, which tRPC supports but isn't the default.
Client Flexibility
| REST | GraphQL | tRPC | |
|---|---|---|---|
| Browser (fetch) | ✅ | ✅ | TypeScript only |
| Mobile (Swift/Kotlin) | ✅ | ✅ | ❌ |
| Third-party integrations | ✅ | ✅ | ❌ |
| Non-TypeScript clients | ✅ | ✅ | ❌ |
| curl / Postman | ✅ | ✅ | Possible |
REST and GraphQL are language-agnostic. tRPC only works for TypeScript clients. If your API will ever be consumed by a mobile app, a third-party developer, or a team using a different language, tRPC is not a viable primary API style.
Team and Scale Dynamics
REST fits when:
- Multiple teams with different clients (mobile, web, third-party)
- Non-TypeScript consumers
- CDN caching is important
- API is public or has external consumers
- Simple data requirements — each endpoint has one clear use case
GraphQL fits when:
- Multiple frontend applications with different data requirements from the same models
- Mobile and web clients that need field-level control over data shape
- Rapidly evolving frontend requirements — new queries don't require backend changes
- Strong schema ownership and code generation discipline in the team
tRPC fits when:
- Full-stack TypeScript with a single frontend application
- You want type safety without a schema language or code generation
- Your backend and frontend are in the same codebase (monorepo)
- API is private — no external consumers
Hybrid Approaches
These styles aren't mutually exclusive.
// REST for public API + tRPC for internal dashboard
const app = express()
app.use('/api/v1', publicRestRouter) // External API — REST with versioning
// tRPC for the internal admin dashboard
app.use('/trpc', trpcExpressMiddleware({ router: appRouter }))
// Or: GraphQL for the mobile app, tRPC for the web app
// Same database queries, different API layersMany production systems use GraphQL for mobile and public APIs, tRPC for internal tooling, and REST for webhooks and third-party integrations — because the right choice depends on the consumer, not the backend.
Decision Framework
| Situation | Choose |
|---|---|
| Public API with external consumers | REST |
| Mobile + web + third-party clients | REST or GraphQL |
| Multiple frontends with different data shapes | GraphQL |
| CDN caching is critical | REST |
| Full-stack TypeScript, single frontend | tRPC |
| Monorepo, backend + frontend same codebase | tRPC |
| Team allergic to code generation | tRPC |
| Non-TypeScript clients exist | REST or GraphQL |
| Real-time subscriptions | GraphQL |
The practical heuristic: if someone other than your TypeScript frontend team will consume this API, tRPC is not the right primary choice. If you have multiple clients with genuinely different data needs, GraphQL earns its complexity. If you have one TypeScript frontend and want the tightest possible type loop, tRPC wins on developer experience.
For tRPC in depth — procedures, context, middleware, and Next.js integration — see the tRPC + Next.js complete guide. For GraphQL with TypeScript including schema design and codegen, see the GraphQL + TypeScript guide. For the comparison between tRPC and Hono RPC specifically, the tRPC vs Hono RPC breakdown covers that comparison in detail.