DEV Community

hilde43
hilde43

Posted on

balancing type‑safe tRPC contracts with GraphQL caching in large Next.js apps

the problem

in a multi‑tenant SaaS we needed a single source of truth for data models while keeping page loads under 200 ms on the first paint. the backend team pushed a tRPC router for internal services, but the frontend product demanded flexible queries for dashboards, so we introduced a thin GraphQL layer on top of the same types. the result was a tangled contract surface that threatened both type safety and cacheability.

the api contract shape

using zod together with tRPC lets us export the exact schema to the client:

import { z } from 'zod'
import { initTRPC } from '@trpc/server'

const t = initTRPC.create()

const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  role: z.enum(['admin', 'member'])
})

export const appRouter = t.router({
  getUser: t.procedure.input(z.string()).output(userSchema).query(({ input }) => {
    return db.user.findUnique({ where: { id: input } })
  })
})
Enter fullscreen mode Exit fullscreen mode

the same userSchema can be fed to a GraphQL generator, keeping the runtime contract identical. however, GraphQL resolvers introduce a per‑field resolver overhead that can break our sub‑200 ms budget if not carefully batched.

performance edge case

the biggest surprise came from cache invalidation. tRPC responses are fully serializable, so we can memoize them with react-query using the procedure name as the query key. GraphQL, on the other hand, relies on the client’s cache normalization, which becomes fragile when you version fields. a naïve approach of adding @deprecated fields caused stale data to linger in the cache, leading to UI flicker.

to mitigate this we introduced a versioned namespace on the GraphQL endpoint:

type Query {
  v2_user(id: ID!): UserV2!
}

type UserV2 {
  id: ID!
  fullName: String!
  role: Role!
}
Enter fullscreen mode Exit fullscreen mode

clients now request v2_user explicitly, and the cache key includes the version string, making eviction deterministic. the trade‑off is a small bump in schema maintenance cost, but it preserves the DX advantage of a single source of truth.

lessons learned

  • keep the type definition source single – zod + codegen bridges tRPC and GraphQL.
  • isolate versioned GraphQL fields to avoid cache churn.
  • profile per‑field resolver cost; batch where possible with DataLoader.
  • document the cache key strategy for any future consumer.

how do you balance type safety and caching when exposing a versioned API in your stack

Top comments (0)