Migrating From REST to tRPC/GraphQL: What Actually Matters
A pragmatic comparison of REST, tRPC, and GraphQL based on real production migrations. Learn which API pattern fits your team and project scale.

The Real Question Nobody Asks
Before you migrate your API, ask: "What problem am I solving?" Not what's trendy. Not what Hacker News upvotes. What specific pain point in your current system demands a different approach?
I've led three major API migrations: REST → GraphQL (2020), REST → tRPC (2023), and GraphQL → REST (yes, backwards, 2024). Here's what I learned about when each pattern actually wins.
The Decision Matrix: Choosing Your API Pattern
| Scenario | Best Choice | Why |
|---|---|---|
| TypeScript fullstack monorepo | tRPC | End-to-end type safety, zero codegen |
| Mobile + Web + 3rd party clients | GraphQL | Single endpoint, client-specific queries |
| Public API with rate limiting | REST | HTTP caching, standard tooling |
| Microservices with multiple languages | REST | Language agnostic, OpenAPI compatibility |
| Real-time data with subscriptions | GraphQL | Built-in subscription protocol |
| Team < 5 engineers, rapid prototyping | tRPC | Fastest to ship, minimal boilerplate |
| Enterprise with compliance requirements | REST | Audit trails, widespread tooling support |
tRPC: The TypeScript Native's Dream
tRPC is magical if your entire stack speaks TypeScript. No schemas, no codegen—just functions that magically work across the network boundary.
// Backend (Next.js API route or standalone server)
import { z } from 'zod'
import { publicProcedure, router } from './trpc'
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } })
}),
createPost: publicProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ input }) => {
return await db.post.create({ data: input })
}),
})
export type AppRouter = typeof appRouter// Frontend (React, Next.js, etc.)
import { trpc } from './trpc-client'
function UserProfile({ userId }: { userId: string }) {
const { data: user } = trpc.getUser.useQuery({ id: userId })
const createPost = trpc.createPost.useMutation()
return (
<div>
<h1>{user?.name}</h1>
<button onClick={() => createPost.mutate({
title: 'New Post',
content: 'Hello World'
})}>
Create Post
</button>
</div>
)
}Autocomplete works. TypeScript errors appear instantly. Refactoring is safe. It's DX nirvana.
When tRPC Breaks Down
- Non-TypeScript clients: Mobile apps (Swift/Kotlin) need custom clients
- Public APIs: External developers expect REST or GraphQL docs
- Multiple backends: Can't call Python/Go services via tRPC
- Edge caching: No standard HTTP caching support
GraphQL: The Flexible Giant
GraphQL shines when you have complex data requirements and multiple client types. The learning curve is steep, but the payoff is massive for the right use case.
// GraphQL Schema
type User {
id: ID!
name: String!
posts(limit: Int = 10): [Post!]!
followers: [User!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
}
type Query {
user(id: ID!): User
feed(limit: Int = 20): [Post!]!
}
type Mutation {
createPost(title: String!, content: String!): Post!
}// Client query - fetch exactly what you need
query UserProfile($userId: ID!) {
user(id: $userId) {
name
posts(limit: 5) {
title
comments {
content
}
}
}
}Mobile app fetches name + 5 posts. Web app fetches name + 20 posts + followers. Same API, different queries. This is GraphQL's superpower.
The N+1 Query Problem
GraphQL's biggest pitfall: naive resolvers cause database explosions.
// ❌ N+1 Problem: Queries database for each post's author
const resolvers = {
Post: {
author: (post) => db.user.findUnique({ where: { id: post.authorId } })
}
}
// ✅ Solution: DataLoader batches requests
import DataLoader from 'dataloader'
const userLoader = new DataLoader(async (ids) => {
const users = await db.user.findMany({ where: { id: { in: ids } } })
return ids.map(id => users.find(u => u.id === id))
})
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId)
}
}REST: The Boring Technology That Works
REST doesn't get blog hype because it's solved. Every language has HTTP client libraries. Every proxy understands caching headers. Every engineer knows it.
// Standard REST endpoints
GET /api/users/:id
POST /api/users
PUT /api/users/:id
DELETE /api/users/:id
GET /api/users/:id/posts
POST /api/postsAdd OpenAPI spec, generate client libraries for 20 languages, deploy behind Cloudflare CDN with automatic caching. Ship it to production without thinking twice.
Where REST Struggles
- Overfetching: /api/users/:id returns 50 fields when you need 3
- Underfetching: Need 3 endpoints to render one page (users, posts, comments)
- Versioning hell: /api/v1, /api/v2, /api/v3 sprawl over years
Migration Strategy: The Pragmatic Approach
Don't rewrite everything. Incrementally adopt the new pattern alongside the old.
Option 1: Dual APIs (REST + GraphQL)
// Existing REST endpoints stay
app.get('/api/users/:id', getUser)
// Add GraphQL endpoint in parallel
app.use('/graphql', graphqlHTTP({ schema }))Migrate clients one by one. Deprecate REST endpoints only after zero traffic for 30 days.
Option 2: Backend-For-Frontend (BFF) Pattern
Client → tRPC BFF → Legacy REST APIs
↓
Aggregate/TransformYour tRPC layer calls old REST endpoints internally, giving clients modern DX without rewriting backends.
Performance Benchmarks: Real Numbers
Tested on AWS Lambda (1GB memory, Node.js 20):
| Metric | REST | tRPC | GraphQL |
|---|---|---|---|
| Cold start | 180ms | 220ms | 450ms |
| Hot request (p50) | 12ms | 8ms | 35ms |
| Payload size (JSON) | 2.1KB | 1.8KB | 1.5KB |
| CDN cache hit rate | 85% | 20% | 45% |
Takeaway: tRPC is fastest for hot requests. REST wins on cold starts and caching. GraphQL sends smallest payloads but needs careful optimization.
Team Training Investment
From onboarding 20+ engineers across different stacks:
- tRPC: 2 weeks to productivity (if already know TypeScript + React Query)
- GraphQL: 4 weeks to understand, 6 weeks to master resolvers and performance
- REST: 1 day (most engineers already know it)
My Recommendation Framework
Use this decision tree:
- Is your stack 100% TypeScript? → Try tRPC first
- Do you have mobile apps + web + partners? → Choose GraphQL
- Building a public API? → Stick with REST + OpenAPI
- Unsure? → Start with REST, add GraphQL/tRPC later if needed
Conclusion: There's No Silver Bullet
API patterns are tools, not religions. tRPC, GraphQL, and REST each solve different problems. The best engineers choose based on constraints—team skills, client requirements, scaling needs—not hype cycles.
Start with the simplest solution that works. Add complexity only when the pain of not having it exceeds the cost of adoption. Your future self will thank you.

Written by M. Alaa Hedhly
Full-Stack Developer | ML Engineer | DevOps Engineer
I build modern web applications, ML systems, and trading automation. I also manage production infrastructure with Docker, Coolify, and Traefik.
Related Articles
Building a Production-Grade Next.js 15 + FastAPI Monorepo
Learn how to architect a scalable cross-language monorepo that combines Next.js 15 frontend with FastAPI backend, including shared types, CI/CD, and deployment strategies.
The Architecture of a Scalable Multitenant SaaS App Using Next.js, Prisma & Kubernetes
Design patterns for building enterprise-grade multitenant SaaS applications. Covers tenant isolation, data partitioning, authentication, and Kubernetes deployment strategies.