tRPC Type Sharing: Monorepo vs Separate Packages for End‑to‑End Type Safety
Choose how to share tRPC Router types between server and client. Compare monorepo instant sync versus separate packages with versioning, trade‑offs, and validation steps for end‑to‑end type safety.
08 Oct 2026, 08:15 UTC

The problem is schema drift without a schema
tRPC removes a separate schema language by making the server Router the single source of truth. Client types are inferred from that Router with TypeScript's 'infer' keyword so a change to input or output is supposed to surface as a TypeScript error on the client.
The practical question is how to make that Router available to the client without shipping server code or creating a manual build step.
The decision is project structure: share the Router in a Monorepo for instant type sync, or publish it as a separate package for versioned boundaries. The constraint is TypeScript only. tRPC cannot provide native type safety for Go, Python or Rust clients.
Decision and constraints
You need end‑to‑end type safety with minimal maintenance, and you must avoid importing server‑only logic into the client bundle. You also need a strategy for breaking changes when server and client are owned by different teams or deploy on different cadences.
| Option | How types flow | Build step | Coupling |
|---|---|---|---|
| Monorepo shared project | Router lives in apps/server or packages/api and is imported directly by apps/web | None for types. TypeScript project references resolve at compile time | Tight. Server change breaks client build immediately |
| Separate published packages | Router is published as @org/trpc-types or @org/api-schema. Client depends on the published version | Required. Server must build and publish before client can consume | Loose. Version pinning allows independent deploys |
Trade‑offs
Monorepo gives instant feedback. Because the Router is imported directly, renaming a procedure or changing a Zod input schema produces a compile error in the client on the next tsc run. There is no duplication and no release choreography. The risk is tight coupling. A breaking change in the server Router will cause immediate build failures in the client, which can be disruptive in large distributed teams without a versioning strategy. There is also bundle risk: if the server‑side Router file imports Node‑only modules, an accidental import into the client bundle will increase size and fail at runtime.
Separate packages add a version boundary. The server team can release @org/api-schema v1.2.0, and the client team upgrades when ready. This protects independent deploy cadences. The cost is latency and process. Every Router change requires a build and publish step, and the client must be updated to the new package version to see new types. Type errors are delayed until the client upgrades.
Input validation stays decoupled from types in both options. tRPC integrates validation via third‑party libraries like Zod or Yup at procedure definition time. The Zod schema defines runtime validation, while TypeScript types are inferred from it. Keep validation schemas in the shared Router package so both sides agree on shape.
Concrete setup and validation
Server Router with Zod
Place the Router in a shared location. In a Monorepo this can be packages/api/src/router.ts. In a package approach this is the package entrypoint.
// packages/api/src/router.ts
import { z } from 'zod'
import { initTRPC } from '@trpc/server'
import { router, procedure } from '@trpc/server'
const t = initTRPC.create()
export const appRouter = router({
user: router({
getById: procedure
.input(z.object({ id: z.string().uuid() }))
.query(({ input }) => {
// return user
return { id: input.id, name: 'Example' }
})
})
})
export type AppRouter = typeof appRouter
Run this file only on the server. Required permission is read access to the package. Risk: do not export server‑only helpers from the same file that the client imports.
Client inference
The client imports the type only, not the implementation. In a Monorepo the import is a direct workspace reference. In a package approach the import comes from the published package.
// apps/web/src/trpc.ts
import type { AppRouter } from '@org/api'
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc'
})
]
})
Usage then benefits from autocomplete for the input object because the type is inferred from the Router.
trpc.user.getById.query({ id: '...' })
Checking the contract
To verify type propagation, run the TypeScript compiler in the client workspace where to run: client package root. Required permissions: read access to source and workspace dependencies.
npx tsc --noEmit
Expected check: changing the return type in the server procedure, for example adding a required field, should cause tsc to flag client‑side usage that does not handle the new field. This is the practical way to confirm the Router is the single source of truth.
To verify batching, open the browser network tab and trigger multiple tRPC calls in quick succession. With httpBatchLink enabled, tRPC groups multiple procedure calls into a single HTTP request to reduce network overhead. Inspect the request payload to confirm multiple calls are present.
Limitations
tRPC is strictly limited to TypeScript environments. It cannot provide native type safety for clients written in Go, Python or Rust.
Tight coupling can lead to large bundle sizes if server‑side Router logic is accidentally imported into the client bundle. Prevent this by separating type‑only exports and using bundler externals.
Breaking changes in the server Router will cause immediate build failures in the client in a Monorepo. Mitigate with additive changes first, or adopt the package approach with semantic versioning when teams need independence.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.