choosing the right data layer for React apps
The choice of data layer decides type safety, caching behavior and how fast a React team ships new features. REST remains the pragmatic standard, GraphQL brings flexible queries for complex data models, and tRPC delivers end to end type safety without a schema language, provided backend and frontend live in the same TypeScript monorepo.
Table of contents
- 1. Why the data layer decision has architectural consequences
- 2. REST: core principles, strengths and limits
- 3. GraphQL: one endpoint, flexible queries, its own complexity
- 4. tRPC: end to end type safety without a schema language
- 5. Type safety compared: codegen vs. inference
- 6. Caching behavior of the three approaches in React
- 7. Team size, monorepo and backend language as factors
- 8. Migration paths between the three approaches
- 9. REST, GraphQL and tRPC compared directly
- 10. Summary
- 11. FAQ
1. Why the data layer decision has architectural consequences
The decision for a React app's data layer is usually made early in the project and afterward can only be reversed at considerable cost. REST, GraphQL and tRPC differ not only in syntax but in fundamental assumptions about how frontend and backend are coupled, how type safety comes about and how caching works. A wrongly chosen data layer rarely shows up immediately, only once the team grows or the application needs to model more complex data relationships.
This article compares the three approaches along the criteria that actually matter in practice: type safety, caching behavior, team structure and migration effort. The goal is not to recommend a single data layer across the board, but to make visible the tradeoffs each choice brings, so the decision is made deliberately rather than out of habit.
It is also notable that the three approaches produce different failure classes when the data layer was chosen incorrectly. An overly rigid REST API shows up in ever more special purpose endpoints. An unnecessarily complex GraphQL layer shows up in slow resolvers and N plus 1 problems. A hastily introduced tRPC shows up as soon as a second frontend in a different language joins and is suddenly left out entirely. These symptoms help retroactively recognize a wrong decision before it becomes truly expensive.
2. REST: core principles, strengths and limits
REST remains the most widely used data layer for React applications because it builds on HTTP semantics that practically every team already knows: resources through URLs, verbs like GET and POST, status codes for error handling. This simplicity makes REST the obvious choice for teams with mixed backend languages, or when the API is also consumed by mobile clients or third parties that do not speak GraphQL or tRPC.
The limits of REST as a data layer show up with nested data models: an overview page that needs posts, authors and comment counts at the same time requires either several roundtrips or a specially tailored endpoint that quickly becomes an unwieldy catch all. Over fetching and under fetching are the typical symptoms: either an endpoint delivers more fields than a component needs, or exactly the one field that forces a second request is missing.
A proven middle ground within a REST data layer is a backend for frontend, or BFF for short: a slim aggregation layer that bundles several internal REST calls and offers the frontend a single endpoint tailored to the given page. This keeps REST's simplicity, but shifts the aggregation logic from the client into the backend, where it sits closer to the data sources and produces fewer roundtrips over a potentially slow mobile connection.
// api-client.ts — Typed REST client generated from an OpenAPI spec
import createClient from "openapi-fetch";
import type { paths } from "./generated/openapi-types";
const client = createClient<paths>({ baseUrl: "https://api.mironsoft.de" });
// Type-safe REST call: path, method and response shape are all inferred
async function fetchProduct(id: string) {
const { data, error } = await client.GET("/products/{id}", {
params: { path: { id } },
});
if (error) throw new Error("Product fetch failed");
return data; // fully typed from the OpenAPI schema
}
An often underestimated advantage of REST as a data layer is HTTP native cache semantics: headers such as ETag, Cache-Control and Last-Modified work without any extra library and can be evaluated equally by CDNs, reverse proxies and browsers. A GET request against an unchanged product endpoint can be answered entirely from an edge cache without touching the application server, which is considerably harder to achieve with GraphQL because of its single POST endpoint. For public, highly cacheable content, this advantage of REST as a data layer remains practically relevant, independent of the type safety discussion.
3. GraphQL: one endpoint, flexible queries, its own complexity
GraphQL solves REST's over fetching problem by letting clients request exactly the fields a component actually needs, through a single endpoint. For React applications with heavily nested data models, such as e-commerce catalogs with variants, prices and stock levels, this flexibility is a significant advantage over a rigid REST data layer. A client can formulate a query matching exactly one component, instead of orchestrating several REST endpoints.
The downside is additional complexity on the backend side: a GraphQL server needs resolvers, schema definitions and usually a solution for the N plus 1 problem such as DataLoader. For small teams without dedicated backend expertise, this data layer can create more effort than it saves in frontend flexibility. GraphQL pays off especially when several client types, such as web and mobile, consume the same API with different data needs.
# product-detail.graphql — One query replaces three REST roundtrips
query ProductDetail($id: ID!) {
product(id: $id) {
name
priceCents
variants {
sku
stockLevel
}
reviews(first: 5) {
rating
author {
displayName
}
}
}
}
4. tRPC: end to end type safety without a schema language
tRPC pursues a fundamentally different approach for the data layer: instead of defining a separate schema language like SDL, the backend router is written directly as TypeScript code, and the frontend client derives its types straight from that router through TypeScript's inference mechanism. There is no codegen step, no .graphql files and no separate build step for types, because backend and frontend use the same TypeScript compiler.
This elegance has one hard requirement: tRPC only works if backend and frontend live in the same monorepo and are both written in TypeScript. For teams with a Java or PHP backend, tRPC is not an option. For TypeScript full stack teams in a monorepo, tRPC is often the data layer with the lowest maintenance overhead, since a wrong function call is already caught at compile time, not first at runtime in the browser.
Another often underestimated advantage: refactorings that rename a procedure or remove an input field are immediately flagged as errors by the TypeScript compiler at every call site in the frontend. With REST or GraphQL, a forgotten call site often stays undiscovered until runtime, because compiler and API contract are checked separately. This instant feedback makes large refactorings noticeably lower risk in a tRPC data layer than in the other two approaches.
// server/router.ts — tRPC router, plain TypeScript, no schema language
import { z } from "zod";
import { publicProcedure, router } from "./trpc";
export const appRouter = router({
product: {
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.db.product.findUniqueOrThrow({ where: { id: input.id } });
}),
},
});
export type AppRouter = typeof appRouter;
// client.ts — Types are inferred directly from AppRouter, no codegen step
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/router";
export const trpc = createTRPCReact<AppRouter>();
// Component usage: fully typed, autocompletion for every field
function ProductPage({ id }: { id: string }) {
const { data } = trpc.product.byId.useQuery({ id });
return data ? <h1>{data.name}</h1> : null;
}
Another practical aspect of tRPC as a data layer is its integration with middleware and context: authentication, rate limiting and logging can be defined as reusable middleware functions directly in the router, similar to Express or Fastify, but with full type propagation to downstream procedures. A protectedProcedure that already injects the authenticated user into the context in a typed way saves repeated type checks in every single procedure, and surfaces errors from missing authentication already at compile time, not only when testing the route.
5. Type safety compared: codegen vs. inference
Type safety is where the three approaches differ most clearly. REST achieves type safety only through additional tools such as OpenAPI codegen, which generates types from a specification. That specification has to be maintained manually or generated from code annotations, which is an additional source of error: if the generated specification drifts from the actual API, TypeScript still gives the green light.
A frequently overlooked point in the type safety discussion is runtime validation: even with fully correct types on both sides, an external API can deliver deviating data, for example after an unannounced field rename by a third party. Libraries such as Zod therefore complement both REST and tRPC data layer setups with runtime validation, which sensibly complements TypeScript's purely static compile time checking and makes real discrepancies between expected and actual data visible immediately.
GraphQL achieves type safety through codegen tools such as GraphQL Code Generator, which produce typed hooks from the schema and query documents. The codegen step usually runs as a build task and has to be rerun on every schema change. tRPC needs no separate codegen step, because type inference is handled directly by the TypeScript compiler. This directness is the main reason many TypeScript teams prefer tRPC for internal tools and admin interfaces, where no third party consumes the API.
6. Caching behavior of the three approaches in React
An interesting difference between the three data layer approaches shows up in caching. REST endpoints can be cached per URL with TanStack Query, which is intuitive but without normalization: the same product loaded through two different endpoints exists in the cache as two independent copies. GraphQL with Apollo Client or Relay brings built in normalization, where every object is kept consistent regardless of which query document requested it.
These caching differences directly affect the perceived speed of an application, especially when navigating between overview and detail views of the same objects.
A helpful middle ground for tRPC projects that still want a bit more consistency is a thin, custom normalization layer on top of TanStack Query, keeping known object types by ID in a central map and mirroring query results there upon receipt. This is no full replacement for Apollo's automatic normalization, but it noticeably reduces duplicate data holding in larger tRPC data layer projects, without introducing GraphQL.
tRPC usually uses TanStack Query as its caching layer, with the same query key based model as REST, so without automatic normalization across object types. For many applications that is sufficient, because TanStack Query with targeted invalidation via queryClient.invalidateQueries covers most practical cases. Anyone needing true object normalization across multiple views only finds it in GraphQL clients with a built in cache graph, not in REST or tRPC in their default configuration.
An often overlooked aspect of the caching comparison is invalidation granularity: with GraphQL and Apollo, a single cache.modify call on one object is often enough to keep all affected views consistent, regardless of which query originally loaded that object. With REST and tRPC using TanStack Query, you instead need to explicitly know which query keys are affected by a change and mark them specifically with invalidateQueries, which can become its own small bookkeeping task in the code for complex dependencies between query keys.
7. Team size, monorepo and backend language as factors
The choice of data layer depends more on organizational factors than many technical discussions suggest. A small team with TypeScript on both sides in a monorepo benefits the most from tRPC, because friction between frontend and backend practically disappears. A larger company with several backend languages, mobile clients and external API consumers often fares better with REST or GraphQL, because both work independently of language.
The onboarding speed of new team members also differs noticeably between the three approaches. A REST data layer with clear OpenAPI documentation is usually the fastest for new developers to understand, because the concept is familiar from nearly every web experience. GraphQL demands additional understanding of schema, resolvers and query language, while tRPC needs almost no ramp up time, provided the new person is already comfortable with TypeScript, because the router is simply normal, readable application code.
GraphQL pays off especially when several frontend teams use the same API with different data needs, for example a web team and a mobile team that each need different fields of the same objects. In this scenario, GraphQL prevents the backend team from having to maintain a separate REST endpoint for every client. For a single React team without this requirement, the additional effort of a GraphQL data layer is often not justified.
8. Migration paths between the three approaches
A complete change of data layer in a running application rarely makes sense in one step. The usual path from REST to GraphQL goes through a GraphQL server that wraps existing REST endpoints as resolvers, so called schema stitching or a BFF layer, so frontend teams can gradually switch to GraphQL queries while the backend stays unchanged. A direct big bang switch without a transition phase almost always leads to delays, because it is easy to underestimate how many implicit assumptions are baked into the existing REST code.
The same gradual approach also pays off when migrating from GraphQL to tRPC, for instance because a team finds the resolver effort no longer justifies the benefit: new, internally used admin areas get implemented through tRPC procedures, while the public API consumed by several clients stays on GraphQL. This hybrid data layer with two parallel access paths looks inelegant at first, but in practice is often the most pragmatic solution, because it serves each use case with the right tool instead of enforcing a single technology at any cost.
Switching to tRPC only makes sense if the backend is already in TypeScript or is being migrated. In this case, tRPC can run alongside existing REST endpoints, with new features implemented directly through tRPC procedures while old endpoints phase out. This gradual coexistence is considerably lower risk than a complete reimplementation of the data layer and allows the benefit to be validated early in the project.
Regardless of the target architecture: a big bang switch of the data layer without feature flags or a transition phase ties up disproportionate team capacity and often blocks parallel feature development for weeks. Successful migrations therefore run almost always incrementally, endpoint by endpoint or view by view, with clear metrics proving that the new data layer actually delivers the hoped for advantage in type safety or caching, before the next part of the application is migrated.
9. REST, GraphQL and tRPC compared directly
The following table summarizes the decisive differences and serves as a quick reference for team decision making. It does not replace an individual analysis, but makes the most important tradeoffs visible at a glance before a decision on the data layer is made.
If unsure, start small: implement a single feature through the favored data layer as a trial, gather team feedback after two to three sprints, and only then extend the decision to further parts of the application.
| Criterion | REST | GraphQL | tRPC |
|---|---|---|---|
| Type safety | Via OpenAPI codegen | Via schema + codegen | Native via inference |
| Backend language | Any | Any | TypeScript only |
| Cache normalization | None, per URL | Object based | None, per query key |
| Over/under fetching | Common problem | Solved via queries | Per procedure, low |
| External consumers | Very well suited | Suited, more effort | Not suited |
| Setup effort | Low | High | Low in a monorepo |
An aspect not directly visible in the table is error handling at the interface: REST communicates errors through HTTP status codes, which every client, firewall and monitoring tool understands without extra knowledge. GraphQL often returns an HTTP 200 status with a separate errors array on partial failures, which existing HTTP monitoring tools can easily miss without GraphQL specific adjustment. tRPC uses its own typed error classes, which can be handled precisely on the client side, but also require some ramp up before monitoring and alerting work correctly.
None of these three data layer options is universally superior. REST remains the right choice for open APIs and mixed backend landscapes, GraphQL for complex data models with several client types, and tRPC for TypeScript full stack teams wanting maximum type safety with minimal setup. The table makes visible that the decision is less a technology question and more a question of team structure.
Mironsoft
Architecture consulting for REST, GraphQL and tRPC data layers
Not sure which data layer fits your team?
We analyze team structure, backend landscape and data model, and recommend a data layer that fits the actual situation, not the current hype.
Architecture review
Evaluation of an existing REST or GraphQL API for type safety and caching
tRPC introduction
Setup in TypeScript monorepos with TanStack Query integration
Migration
Gradual transition via a BFF layer without big bang risk
10. Summary
Choosing the data layer for a React app is not a matter of taste, but depends on team structure, backend language and the complexity of the data model. REST remains the pragmatic default choice for open APIs and mixed language landscapes, but requires additional tools for type safety. GraphQL solves over fetching through flexible queries, but brings resolver complexity and a codegen step with it.
tRPC delivers the most direct type safety through TypeScript inference without a schema language, but is strictly tied to a TypeScript monorepo and unsuitable for external API consumers. In caching, only GraphQL offers true object normalization across views, while REST and tRPC rely on query key based caching with TanStack Query. The right data layer is the one that fits the actual team structure, not the technically most elaborate option.
Whoever documents this decision, for example as a short architecture decision record, saves the team later fundamental discussions once new members join and raise the same question again.
REST vs. GraphQL vs. tRPC, the essentials at a glance
REST
Pragmatic, language independent, but prone to over and under fetching without dedicated endpoints.
GraphQL
Flexible queries through one endpoint, object based cache normalization, higher backend effort.
tRPC
Native type safety without codegen, but usable only in a TypeScript monorepo.
Decision
Team structure and backend language decide, not technical preference alone.