Choosing an API style is one of the first architectural decisions in a new project, and the wrong choice creates friction for years. REST, GraphQL, and tRPC each solve real problems — but they solve different problems. This guide gives you a clear, practical comparison so you can make the right call for your specific situation.
REST: The Standard
REST (Representational State Transfer) is an architectural style built around HTTP verbs and resource URLs. Every resource has a URL; you interact with it using GET, POST, PUT, PATCH, and DELETE.
# REST endpoints for a blog
GET /posts # list all posts
GET /posts/42 # get one post
POST /posts # create a post
PUT /posts/42 # replace a post
PATCH /posts/42 # partial update
DELETE /posts/42 # delete a post
GET /posts/42/comments # nested resource
A Simple REST API in Node.js (Express)
import express from 'express'
import { prisma } from './lib/prisma'
const app = express()
app.use(express.json())
// GET /posts
app.get('/posts', async (req, res) => {
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
})
res.json(posts)
})
// POST /posts
app.post('/posts', async (req, res) => {
const { title, content, authorId } = req.body
const post = await prisma.post.create({
data: { title, content, authorId, published: false },
})
res.status(201).json(post)
})
app.listen(3000)
REST Strengths
- Universal support — every language, framework, and tool speaks HTTP/REST
- HTTP caching — GET responses are cacheable by browsers, CDNs, and proxies out of the box
- Stateless — each request is self-contained; easy to scale horizontally
- Familiar to everyone — no learning curve for experienced developers
- Easy to test — curl, Postman, browser DevTools all work natively
REST Weaknesses
- Over-fetching —
GET /usersreturns all fields even if you only needname - Under-fetching — displaying a post with author and comments requires 3 requests:
/posts/1,/users/5,/posts/1/comments - No contract enforcement — clients and servers drift apart; the only contract is documentation
- Versioning pain —
/api/v1/,/api/v2/sprawl as the API evolves
GraphQL: The Flexible Query Language
GraphQL is a query language for APIs. Instead of fixed endpoints, you have a single endpoint (POST /graphql) and clients declare exactly what data they need.
# Client sends this query
query GetPost($id: ID!) {
post(id: $id) {
title
content
author {
name
avatar
}
comments(first: 5) {
text
createdAt
}
}
}
The server returns exactly that shape — no more, no less. One request replaces three REST calls, and there's no over-fetching.
GraphQL Server (Apollo with Prisma)
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { prisma } from './lib/prisma'
const typeDefs = `#graphql
type Post {
id: ID!
title: String!
content: String
author: User!
comments: [Comment!]!
}
type User { id: ID!; name: String! }
type Comment { id: ID!; text: String! }
type Query {
post(id: ID!): Post
posts(published: Boolean): [Post!]!
}
type Mutation {
createPost(title: String!, content: String, authorId: ID!): Post!
}
`
const resolvers = {
Query: {
post: (_, { id }) => prisma.post.findUnique({ where: { id: Number(id) } }),
posts: (_, { published }) => prisma.post.findMany({ where: { published } }),
},
Post: {
author: (post) => prisma.user.findUnique({ where: { id: post.authorId } }),
comments: (post) => prisma.comment.findMany({ where: { postId: post.id } }),
},
Mutation: {
createPost: (_, args) => prisma.post.create({ data: args }),
},
}
const server = new ApolloServer({ typeDefs, resolvers })
await startStandaloneServer(server, { listen: { port: 4000 } })
GraphQL Strengths
- Precise data fetching — clients get exactly what they ask for
- Single request for complex data — fetch nested resources in one round trip
- Strongly typed schema — the schema IS the contract; both client and server are bound to it
- Introspection — tools like GraphiQL auto-generate documentation from the schema
- Evolve without versioning — add fields freely; deprecate old ones with
@deprecated
GraphQL Weaknesses
- N+1 problem — fetching 100 posts with their authors triggers 101 DB queries without DataLoader
- Caching is hard — HTTP caching doesn't work on a single POST endpoint; needs client-side caching (Apollo Client, urql)
- Complexity overhead — schema definition, resolvers, and a client library for a simple CRUD app is overkill
- File uploads are awkward — requires multipart spec; REST handles uploads natively
tRPC: Type-Safe APIs Without a Schema
tRPC takes a completely different approach: it generates a type-safe client directly from your server's TypeScript types. There's no schema language, no code generation step, no documentation to maintain — the server IS the contract.
// server/router.ts
import { router, publicProcedure } from './trpc'
import { z } from 'zod'
import { prisma } from './lib/prisma'
export const postRouter = router({
list: publicProcedure
.input(z.object({ published: z.boolean().optional() }))
.query(({ input }) => {
return prisma.post.findMany({ where: input })
}),
byId: publicProcedure
.input(z.object({ id: z.number() }))
.query(({ input }) => {
return prisma.post.findUnique({ where: { id: input.id } })
}),
create: publicProcedure
.input(z.object({ title: z.string().min(1), content: z.string().optional() }))
.mutation(({ input, ctx }) => {
return prisma.post.create({
data: { ...input, authorId: ctx.user.id },
})
}),
})
// client/posts.tsx — full type safety, zero schema writing
import { trpc } from './trpc'
function Posts() {
const { data: posts } = trpc.post.list.useQuery({ published: true })
const createPost = trpc.post.create.useMutation()
// ↑ TypeScript knows exactly what this returns
// No type assertion, no manual interface
return (
<>
{posts?.map(post => <div key={post.id}>{post.title}</div>)}
<button onClick={() => createPost.mutate({ title: 'New Post' })}>
Create
</button>
</>
)
}
tRPC Strengths
- Zero type drift — change a return type on the server; TypeScript errors appear in the client immediately
- No code generation — no schema compilation, no graphql-codegen, no OpenAPI spec
- First-class React Query integration —
useQueryanduseMutationhooks built in - Input validation with Zod — automatic runtime validation + TypeScript type inference from the same schema
- Minimal boilerplate — write a procedure, call it from the client; that's it
tRPC Weaknesses
- TypeScript only — tRPC is useless without TypeScript on both client and server; a React Native app can use it but an Android/iOS native app cannot
- Monorepo-optimized — works best when client and server share a codebase. Cross-repo or multi-team setups lose the type-sharing benefit
- Not a public API — you can't expose tRPC as a third-party API; it's internal machinery, not a documented contract
- Limited ecosystem — far fewer tools, middleware, and tutorials compared to REST or GraphQL
Head-to-Head Comparison
Caching
- REST: Native HTTP caching — GET requests are cached by browsers and CDNs automatically
- GraphQL: HTTP caching doesn't apply to POST; use Apollo Client's normalized cache or persisted queries
- tRPC: GET requests for queries (default), so HTTP caching works; React Query handles client-side caching
Third-Party Consumers
- REST: Any client in any language — the gold standard for public APIs
- GraphQL: Any client with a GraphQL library; the schema is self-documenting
- tRPC: TypeScript only — not suitable for external/public APIs
Team Size and Structure
- REST: Works for any team structure; frontend and backend can be fully decoupled
- GraphQL: Scales well when frontend teams need data autonomy — they write their own queries
- tRPC: Best for small full-stack teams or a single developer owning the entire stack
Which to Choose?
Choose REST if:
- You're building a public API consumed by third parties
- Your clients are diverse (mobile apps, external partners, IoT devices)
- Caching at the CDN layer is critical to performance
- Your team includes developers not using TypeScript
Choose GraphQL if:
- You have multiple frontend clients (web, mobile) that each need different data shapes
- Frontend teams need to iterate on data requirements without backend changes
- You're dealing with deeply nested, interconnected data (social graph, product catalog)
- You need fine-grained permissions at the field level
Choose tRPC if:
- You're building a full-stack TypeScript app (Next.js, Remix)
- Client and server live in the same repo
- The API is internal — never exposed to third parties
- You want the fastest path to a type-safe backend without a GraphQL schema
Summary
REST is the default for public APIs and anything polyglot. GraphQL shines when you have multiple clients with divergent data needs. tRPC is the best developer experience for TypeScript full-stack apps where the API is internal. These aren't mutually exclusive either — a large product might use tRPC for its internal dashboard, REST for its public API, and GraphQL for its mobile apps, all from the same backend.
CREESOL