Skip to content
result-rpc
Esc
navigateopen⌘Jpreview

Migrating from tRPC

Per-router coexistence, a mechanical concept mapping, and the auth-layer-first first slice.

Migration is per-router, not big-bang. result-rpc is a separate endpoint with a separate client — it shares nothing with tRPC at runtime, so both stacks run side by side for as long as the migration takes:

// server: two handlers, two routes
app.all("/api/trpc/*", trpcHandler); // existing routers stay
app.post("/rpc", resultRpcHandler); // migrated routers move here

// client: two clients during the transition
export const trpc = createTRPCReact<LegacyRouter>();
export const client = createBrowserClient({
  contract,
  transport: batchFetchTransport({ url: "/rpc" }),
});

One difference to internalize before your first slice: tRPC ships its router to the client as a type (createTRPCReact<LegacyRouter>), erased at build. result-rpc ships a real client value built from your contract — so what you import decides what bundles. Pass the contract, never the server router, or handlers and secrets ship to the browser. This is the one migration mistake that is a security bug; read The client boundary first.

The recommended first slice is the auth layer plus one feature router — small enough to finish in days, and it exercises the part tRPC cannot express (a shell owning session expiry) so the migration proves its value immediately instead of at the end.

The concept mapping is mechanical:

tRPC result-rpc
initTRPC.context<Ctx>().create() rpc.context<Ctx>() (browser-safe contract) + serverRpc.context<Ctx>() (server)
t.procedure.input(z...).output(...) — the definition app.procedure().input(wire...).output(wire...).errors({...}).query() — in the contract, no handler
.query(fn) / .mutation(fn) — the resolver server.implement(contract).handler(fn)
throw new TRPCError({ code }) return err(errors.SomeError({...}))
t.middleware + ctx spread server.middleware<Added>().errors({...}).use(...)
protectedProcedure an authenticated middleware, applied per server.implement(contract).use(authenticated)
httpBatchLink batchFetchTransport
@trpc/react-query hooks useResultQuery / shell hooks
errorFormatter gone — error data is a wire codec, not a formatted shape
adapter onError onError + onInternalError on createFetchHandler
createCaller createServerClient(router, { context })
ctx.resHeaders / responseMeta .headers() on the procedure, then context.headers
queryClient.setDefaultOptions({ onError }) a shell

One row deserves a note, because it is a scar many tRPC codebases carry. ctx.resHeaders works under httpBatchLink and silently stops working under httpBatchStreamLink — a streamed response sends its headers before the procedures resolve, so a cookie set inside a mutation is dropped with no error. The usual workaround is to move it into responseMeta, which runs before the result exists. Here the capability is declared with .headers(), batches do not stream, and an undeclared procedure has no context.headers to write to — so the failure mode is a type error rather than a missing cookie. See Setting response headers.

Two things have no tRPC equivalent and are the actual work: every procedure declares its error union (this is where the two-failure-channel debt gets paid down, one procedure at a time), and interceptor logic moves into shells. There is no codemod; each procedure is a five-minute mechanical rewrite — the one .query(fn) splits into a handler-free declaration on the browser-safe contract and a server.implement(contract).handler(fn) on the server, which is what lets the coexistence client above import a contract and never the router.

During coexistence the two stacks keep separate caches — a result-rpc mutation does not invalidate tRPC queries or vice versa. Migrate whole features, not halves of one screen, and the seam stays invisible.

Was this page helpful?