REST vs GraphQL vs tRPC
Three Ways to Talk to Your Backend
Every frontend app needs to fetch data. The question is not whether you need an API layer -- it is which paradigm to choose. REST, GraphQL, and tRPC each optimize for different constraints, and the "right" choice depends on your team, your backend, and your product.
Here is the thing most teams get wrong: they choose based on hype, not constraints. GraphQL because Netflix uses it. tRPC because a conference talk made it look magical. REST because "it just works." Let us actually understand the trade-offs.
Think of ordering food. REST is a fixed menu -- you pick from predefined meals (endpoints). Each meal is a complete dish, even if you only wanted the salad. GraphQL is a buffet -- you walk up and take exactly what you want, but the restaurant needs a complex serving system. tRPC is a private chef who speaks your language -- you tell them exactly what you want, and they know your dietary preferences (types) without asking. Each model has different costs and works for different dining situations.
REST: The Reliable Default
REST maps HTTP methods to CRUD operations on resources. You already know this.
GET /api/courses → List courses
GET /api/courses/:id → Get one course
POST /api/courses → Create a course
PUT /api/courses/:id → Update a course
DELETE /api/courses/:id → Delete a course
GET /api/courses/:id/topics → List topics for a course
Strengths:
- Universal understanding -- every backend engineer knows REST
- HTTP caching works out of the box (
Cache-Control, ETags, CDN caching) - Simple tooling -- browser devtools, curl, Postman
- Stateless -- each request is self-contained
- Works with any backend language
Weaknesses:
- Overfetching:
GET /courses/:idreturns the entire course object when you only need the title - Underfetching: a dashboard needs courses, user progress, and notifications -- that is three separate requests
- N+1 problem: listing 20 courses with their authors means 1 + 20 requests (or a custom endpoint)
- Type drift: response types live in your head or in hand-written TypeScript interfaces
interface CourseListItem {
id: string;
title: string;
description: string;
thumbnail: string;
difficulty: "beginner" | "intermediate" | "advanced" | "expert";
topicCount: number;
estimatedHours: number;
author: { id: string; name: string; avatar: string };
tags: string[];
createdAt: string;
updatedAt: string;
}
const res = await fetch("/api/courses");
const courses: CourseListItem[] = await res.json();
You wanted title and thumbnail for the card grid. You got 12 fields. That extra data costs bandwidth, parse time, and memory. At scale (mobile devices, slow networks), this adds up.
GraphQL: The Precise Query Language
GraphQL lets the client specify exactly which fields it needs.
query CourseCards {
courses {
id
title
thumbnail
difficulty
author {
name
}
}
}
The response contains exactly those fields. Nothing more.
Strengths:
- Zero overfetching -- request exactly what you need
- Single request for complex data needs -- courses, user, notifications in one query
- Strongly typed schema -- the schema IS the documentation
- Introspection -- clients can discover the API without docs
- Excellent for apps with many different views of the same data
Weaknesses:
- Complexity: requires a GraphQL server, schema definition, resolvers
- Caching is harder: REST endpoints have natural cache keys (
/courses/123). GraphQL queries are POST requests with arbitrary bodies -- CDN caching does not work out of the box - Bundle size: Apollo Client adds 30-50KB to your client bundle.
urqlis lighter (~12KB) but still non-trivial - N+1 on the server: naive resolvers trigger database N+1 queries. Requires DataLoader or similar batching
- Security surface: clients can craft expensive queries (deeply nested, wide selections). Rate limiting by query complexity is non-trivial
const COURSE_CARDS_QUERY = gql`
query CourseCards {
courses {
id
title
thumbnail
difficulty
author {
name
}
}
}
`;
function CourseGrid() {
const { data, loading, error } = useQuery(COURSE_CARDS_QUERY);
if (loading) return <CourseGridSkeleton />;
if (error) return <ErrorState message="Failed to load courses" />;
return (
<div className="grid grid-cols-3 gap-6">
{data.courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
);
}
GraphQL does not magically solve performance problems. If your GraphQL server naively resolves each field with a separate database query, a single client query can trigger hundreds of database calls. DataLoader is mandatory for production GraphQL. And the client-side caching (normalized cache in Apollo, document cache in urql) adds its own complexity and memory overhead.
tRPC: Type Safety Without the Ceremony
tRPC takes a radically different approach: if your frontend and backend are both TypeScript, why define types twice? Types flow from the server function definition to the client call with zero schema, zero codegen, zero runtime overhead.
const appRouter = router({
course: router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const course = await db.course.findUnique({
where: { id: input.id },
select: { id: true, title: true, description: true, difficulty: true },
});
return course;
}),
list: publicProcedure
.input(z.object({
difficulty: z.enum(["beginner", "intermediate", "advanced", "expert"]).optional(),
limit: z.number().min(1).max(100).default(20),
}))
.query(async ({ input }) => {
return db.course.findMany({
where: input.difficulty ? { difficulty: input.difficulty } : undefined,
take: input.limit,
});
}),
}),
});
export type AppRouter = typeof appRouter;
Client-side:
import type { AppRouter } from "@/server/router";
const trpc = createTRPCReact<AppRouter>();
function CourseDetail({ courseId }: { courseId: string }) {
const { data: course } = trpc.course.getById.useQuery({ id: courseId });
if (!course) return null;
return <h1>{course.title}</h1>;
}
Full autocomplete. Type errors at compile time. Change the server return type and the client breaks immediately in your editor, not in production.
Strengths:
- End-to-end type safety with zero codegen or schema files
- Tiny bundle (~4KB for the client)
- Input validation with Zod on the server, inferred types on the client
- Subscriptions, mutations, and queries with the same DX
- Batching built-in -- multiple parallel calls are automatically batched into one HTTP request
Weaknesses:
- TypeScript-only: both frontend and backend must be TypeScript
- Tight coupling: frontend and backend share types at the package level. Monorepo required
- Not for public APIs: tRPC is for internal app communication, not for third-party consumers
- Less ecosystem: no equivalent of Apollo DevTools, GraphiQL, or the GraphQL community tooling
Head-to-Head Comparison
| Factor | REST | GraphQL | tRPC |
|---|---|---|---|
| Type safety | Manual (hand-written types or OpenAPI codegen) | Schema-first with codegen | Automatic -- types flow from server to client |
| Client bundle size | Zero (native fetch) | 12-50KB (urql/Apollo) | ~4KB |
| HTTP caching | Excellent (CDN, Cache-Control, ETags) | Difficult (POST-based queries) | Possible but manual |
| Overfetching | Common problem | Solved -- request only needed fields | Depends on server implementation |
| Backend language | Any | Any (with GraphQL server) | TypeScript only |
| Public API suitability | Excellent | Good (with complexity limits) | Not suitable -- internal only |
| Learning curve | Low | Medium-High | Low (for TypeScript teams) |
| Tooling | curl, Postman, browser devtools | GraphiQL, Apollo DevTools, Playground | tRPC Panel, TypeScript autocompletion |
| Best for | Simple CRUD, external APIs, microservices | Complex data requirements, multiple clients | Full-stack TypeScript monorepos |
When to Use Each
- 1REST: external-facing APIs, simple CRUD, teams with non-TypeScript backends, when HTTP caching is critical
- 2GraphQL: complex data requirements with many views of the same entities, mobile apps needing bandwidth efficiency, multiple client platforms (web, mobile, TV)
- 3tRPC: full-stack TypeScript apps in a monorepo, internal tools and dashboards, rapid prototyping where type safety accelerates iteration
- 4You can mix them: tRPC for internal communication, REST for webhooks and external integrations, GraphQL for mobile clients
The BFF Pattern (Backend for Frontend)
What if your frontend needs data that does not map cleanly to your backend services? The BFF pattern places a thin server between your frontend and your backend services.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Web Frontend │ │ Mobile App │ │ Admin Panel │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Web BFF │ │ Mobile BFF │ │ Admin BFF │
│ (Next.js API) │ │ (lightweight) │ │ (full data) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────┬───────────┘───────────┬───────────┘
▼ ▼
┌─────────────┐ ┌─────────────┐
│ User Service │ │ Course Service│
└─────────────┘ └─────────────┘
Each BFF is tailored to its frontend:
- Web BFF aggregates data for server-rendered pages (courses + progress + recommendations in one call)
- Mobile BFF returns minimal payloads optimized for bandwidth
- Admin BFF returns full, unfiltered data for management views
In Next.js, Server Components act as a natural BFF layer -- they run on the server, can call multiple services, and send only the rendered HTML to the client.
async function DashboardPage() {
const [courses, progress, recommendations] = await Promise.all([
courseService.getEnrolled(),
progressService.getSummary(),
recommendationService.getPersonalized(),
]);
return (
<DashboardLayout>
<ProgressSummary progress={progress} />
<CourseGrid courses={courses} />
<RecommendationCarousel items={recommendations} />
</DashboardLayout>
);
}
Three service calls, zero waterfall, zero overfetching on the client. The client receives pre-composed HTML.
API Gateway Pattern
An API gateway sits between all clients and all backend services. It handles:
- Routing: directs
/api/courses/*to the course service,/api/users/*to the user service - Authentication: validates tokens once, forwards authenticated context
- Rate limiting: protects backend services from abuse
- Response aggregation: combines data from multiple services into one response
- Protocol translation: GraphQL in, REST to backend services
┌─────────────────┐
│ All Clients │
└────────┬────────┘
│
▼
┌─────────────────┐
│ API Gateway │ ← Auth, rate limit, route, aggregate
└────────┬────────┘
┌────┼────┐
▼ ▼ ▼
┌──────┐┌──────┐┌──────┐
│Course││User ││Quiz │
│Svc ││Svc ││Svc │
└──────┘└──────┘└──────┘
In practice, many teams use Next.js middleware and API routes as a lightweight API gateway. For larger scale, dedicated solutions like Kong, AWS API Gateway, or GraphQL federation handle the complexity.
When GraphQL Federation Makes Sense
GraphQL Federation (Apollo Federation) lets multiple backend teams own their part of a unified GraphQL schema. The course team defines Course types, the user team defines User types, and a federation gateway composes them into one schema.
This makes sense when:
- You have 5+ backend teams each owning their domain
- You need a unified API for frontend teams
- Teams deploy independently but need schema compatibility
It does NOT make sense when:
- You have a single backend team
- Your API is simple CRUD
- You do not already use GraphQL
Federation adds significant operational complexity (schema registry, composition validation, gateway deployment). It is a solution for organizations, not small teams.
| What developers do | What they should do |
|---|---|
| Choosing GraphQL because big companies use it GraphQL adds complexity (server, client library, caching strategy, security). If REST solves your problem, the simpler choice wins. | Choosing based on concrete requirements: client data diversity, team skills, backend language |
| Using tRPC for a public API consumed by third-party developers tRPC requires the consumer to import your TypeScript types. Third-party developers using Python or Go cannot use tRPC. Public APIs need language-agnostic protocols. | Using REST or GraphQL for public APIs, tRPC for internal communication |
| Building a GraphQL server that just wraps REST endpoints 1:1 A GraphQL wrapper around REST that does not resolve relationships or reduce overfetching gives you all the complexity of GraphQL with none of the benefits. | Either use REST directly or design a proper GraphQL schema that leverages the graph |