Web Development

Trying tRPC instead of GraphQL for internal, single-consumer services

DEV

GraphQL earns its complexity when multiple, differently-shaped consumers need the same data through a single flexible query interface, a public API serving both a web client and a mobile app with genuinely different data needs is the textbook case where that flexibility pays for itself. For internal tools with exactly one frontend consumer, that flexibility is often pure overhead, a schema layer, resolver logic, and query flexibility that nobody on the single consuming team actually needs, so we tried tRPC on a few of them instead this year.

End-to-end type safety without maintaining a separate schema layer removed real boilerplate for these single-consumer cases. With GraphQL, even a small internal tool ends up needing a schema definition, resolvers that map that schema onto actual data access, and a client-side code generation step to get type safety on the frontend, three distinct layers of work for a service that has exactly one consumer who could, in principle, just call a typed function directly. tRPC's approach collapses that down to defining a typed procedure on the server and calling it directly from the client with full type inference, no schema definition step and no code generation step standing between writing the server logic and consuming it with type safety on the frontend. A typical internal procedure and its call site look like this:

export const orderRouter = router({
  getById: procedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      return ctx.db.order.findUniqueOrThrow({ where: { id: input.id } });
    }),
});

// client
const order = await trpc.order.getById.query({ id: 'ord_123' });

The GraphQL equivalent of that same single lookup needs three separate pieces before a client can call it with type safety: a schema definition, a resolver, and generated client types. The schema and resolver alone look like this:

type Order {
  id: ID!
  status: String!
  total: Float!
}

type Query {
  orderById(id: ID!): Order!
}

That schema by itself doesn't get a frontend engineer anything yet, it still needs a resolver implementing `orderById` and a client-side codegen run before a component can call it with the same type safety the tRPC version got for free.

The developer experience difference was most obvious during active development, when an API shape is still evolving. Changing a GraphQL schema field's type meant updating the schema, regenerating client types, and fixing whatever broke, three separate steps even for a small change. The equivalent tRPC change is just changing the TypeScript type on the server, with the client immediately seeing a type error anywhere it needs updating, no regeneration step required.

Changing `total` from a float to a structured money type is a small, one-line change on the tRPC side; the equivalent GraphQL change touches three separate files before it's actually usable:

- type Order {
-   total: Float!
- }
+ type Order {
+   total: Money!
+ }
+
+ type Money {
+   amount: Int!
+   currency: String!
+ }

That schema edit alone doesn't finish the job, the resolver has to be updated to return the new shape and the client's generated types have to be regenerated before the frontend even sees a type error pointing at what needs to change, three separate steps for a change that's a single edited line in the tRPC version.

The difference shows up even at the raw request level. Calling the tRPC procedure directly is a plain HTTP GET with the input serialized as a query parameter:

curl -s "https://internal.example.com/api/trpc/order.getById?input=%7B%22id%22%3A%22ord_123%22%7D"

Hitting the GraphQL endpoint for the equivalent data means POSTing a query document instead, which is a perfectly reasonable API design on its own, it's just more machinery than a single-consumer internal tool actually needs. For internal tools that iterate quickly and don't need to serve external consumers with API stability guarantees, that faster loop mattered more day to day than any theoretical advantage GraphQL's flexibility offered.

We haven't replaced GraphQL anywhere it's genuinely serving multiple, differently-shaped consumers, and we don't think teams should treat this as a wholesale replacement recommendation. A couple of our client-facing APIs continue to use GraphQL specifically because they're consumed by both a web frontend and a mobile app with real differences in what data each one needs per screen, exactly the scenario GraphQL's field-selection flexibility was designed to solve well. Forcing those into a tRPC-style single-consumer model would mean giving up real value GraphQL provides there.

tRPC has become our default for anything simpler: internal admin tools, single-team dashboards, and services where the frontend and backend are maintained by the same small team and evolve together. The decision rule we've settled into is straightforward, count the genuinely distinct consumers a service needs to support with meaningfully different data shapes; more than one strongly favors GraphQL or a similarly flexible API layer, exactly one favors tRPC's simpler, more directly typed approach. It's a smaller decision than either technology's advocates sometimes make it sound, but getting it right per project has removed a meaningful amount of unnecessary boilerplate across our internal tooling this year.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients