Skip to content

REST vs GraphQL vs tRPC

expert19 min read

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.

Mental Model

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/:id returns 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.

Quiz
A mobile app calls GET /api/courses which returns 50 fields per course. The app only uses 5 fields. What is the primary cost?

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. urql is 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>
  );
}
Common Trap

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
Quiz
Your company has a Python backend and a React frontend. Which API approach gives you the best type safety?

Head-to-Head Comparison

FactorRESTGraphQLtRPC
Type safetyManual (hand-written types or OpenAPI codegen)Schema-first with codegenAutomatic -- types flow from server to client
Client bundle sizeZero (native fetch)12-50KB (urql/Apollo)~4KB
HTTP cachingExcellent (CDN, Cache-Control, ETags)Difficult (POST-based queries)Possible but manual
OverfetchingCommon problemSolved -- request only needed fieldsDepends on server implementation
Backend languageAnyAny (with GraphQL server)TypeScript only
Public API suitabilityExcellentGood (with complexity limits)Not suitable -- internal only
Learning curveLowMedium-HighLow (for TypeScript teams)
Toolingcurl, Postman, browser devtoolsGraphiQL, Apollo DevTools, PlaygroundtRPC Panel, TypeScript autocompletion
Best forSimple CRUD, external APIs, microservicesComplex data requirements, multiple clientsFull-stack TypeScript monorepos

When to Use Each

Key Rules
  1. 1REST: external-facing APIs, simple CRUD, teams with non-TypeScript backends, when HTTP caching is critical
  2. 2GraphQL: complex data requirements with many views of the same entities, mobile apps needing bandwidth efficiency, multiple client platforms (web, mobile, TV)
  3. 3tRPC: full-stack TypeScript apps in a monorepo, internal tools and dashboards, rapid prototyping where type safety accelerates iteration
  4. 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.

Quiz
Your app has a web frontend and a mobile app, both consuming the same REST API. The mobile app needs 5 fields per course, the web app needs 15. What is the best approach?

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 doWhat 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