This episode discusses safe tRPC API evolution: changing schemas without breaking old clients, versioning and procedure deprecation strategies, and input and output migration techniques while maintaining backward compatibility.

Applications are alive: schemas change, fields are added, business rules are updated. The problem is, already-released clients are not always updated along with them. Episode 9 discusses how to change a tRPC API without breaking clients — from backward compatibility principles, versioning strategies, to polite deprecation.
This is the skill that distinguishes an API that can be maintained for years from one that keeps "breaking".
The golden rule of API evolution: additions are always compatible; deletions and type changes are not. Adding a new procedure or field will not break old clients because their types have not changed:
user: t.router({
list: t.procedure.query(() => users),
// tambahan baru: procedure baru tidak mengganggu yang lama
search: t.procedure
.input(z.object({ q: z.string() }))
.query(({ input }) =>
users.filter((u) => u.nama.includes(input.q)),
),
}),Old clients using user.list keep working; new clients can use user.search. As long as we do not remove or change the shape of list, nothing breaks.
When adding requirements, make new fields optional so old requests remain valid:
list: t.procedure
.input(
z.object({
page: z.number().optional().default(1),
filter: z.string().optional(),
}),
)
.query(({ input }) => {
// input.page selalu ada karena default; input.filter bisa undefined
return users
.filter((u) => !input.filter || u.nama.includes(input.filter))
.slice((input.page - 1) * 10, input.page * 10);
}),Old clients that only send { page: 1 } remain valid. The optional filter does not force old clients to change. z.number().optional().default(1) provides a default value while keeping the type number.
tRPC does not provide automatic versioning — you design it. Two common approaches:
Per-procedure versioning: create a new-version procedure with a clear name when a breaking change is unavoidable:
user: t.router({
// versi lama: tetap ada agar client lama tidak pecah
detailV1: t.procedure.input(z.object({ id: z.number() })).query(ambilV1),
// versi baru: bentuk output berbeda
detailV2: t.procedure.input(z.object({ id: z.string() })).query(ambilV2),
}),Namespace versioning: for big changes, wrap the new version in its own router:
export const appRouter = t.router({
v1: v1Router,
v2: v2Router,
});Choose namespace when the changes are far-reaching, and per-procedure suffixes when only one or two endpoints change. The goal is the same: old clients keep running while new clients transition.
When a procedure is replaced, mark the old one as deprecated — via documentation or a JSDoc attribute — and give a transition period:
/** @deprecated Gunakan user.byId sebagai gantinya */
byUsername: t.procedure
.input(z.object({ username: z.string() }))
.query(({ input }) => users.find((u) => u.username === input.username)),The editor will show a strikethrough on user.byUsername when used, signalling the team to move away. Deprecation best practice: allow at least one release cycle before removal, and record the schedule in the changelog.
When the output shape must change drastically, keep the old procedure and add a new one — do not change the old resolver:
const UserV1 = z.object({ id: z.number(), nama: z.string() });
const UserV2 = z.object({ id: z.string(), fullName: z.string(), email: z.string() });
user: t.router({
profile: t.procedure.input(z.object({ id: z.number() })).output(UserV1).query(ambilV1),
profileV2: t.procedure.input(z.object({ id: z.number() })).output(UserV2).query(ambilV2),
}),With .output(UserV1) and .output(UserV2), both versions are explicitly validated and cannot pollute each other. After all clients have moved, profile can be removed in a major release.
The safe order for removing an old procedure:
@deprecated.Throughout the process, note that tRPC is type-safe: if you remove a procedure and a client still uses it, the compiler catches it immediately — an advantage REST does not have, where breaking changes only surface at runtime.
Warning
Be careful with zod .transform() on input. Transform changes the data before the resolver — if the transformation rules change, clients sending old data could receive different results. Keep the transform inside a new procedure when its behavior changes.
Episode 9 teaches disciplined API evolution: adding without breaking, planned versioning, deprecation that gives clear signals, and schema migration that maintains compatibility until all clients have moved.
Key takeaways:
@deprecated and give a transition period.In the next episode, episode 10, we will discuss transport, HTTP, and WebSocket — the choice of httpBatchLink, httpLink, and wsLink, the differences between batching and standard HTTP, as well as proxy, CORS, and request headers configuration.