Migrate from v9 to v10
Welcome to tRPC v10! We're excited to bring you a new major version to continue the journey towards perfect end-to-end type safety with excellent DX.
Under the hood of version 10, we are unlocking performance improvements, bringing you quality of life enhancements, and creating room for us to build new features in the future.
tRPC v10 features a compatibility layer for users coming from v9. .interop()
allows you to incrementally adopt v10 so that you can continue building the rest of your project while still enjoying v10's new features.
Summary of changes
Initializing your server
/src/server/trpc.tsts
/*** This is your entry point to setup the root configuration for tRPC on the server.* - `initTRPC` should only be used once per app.* - We export only the functionality that we use so we can enforce which base procedures should be used** Learn how to create protected base procedures and other things below:* @see https://trpc.io/docs/v10/router* @see https://trpc.io/docs/v10/procedures*/import { initTRPC } from '@trpc/server';import superjson from 'superjson';import { Context } from './context';const t = initTRPC.context<Context>().create({/*** @see https://trpc.io/docs/v10/data-transformers*/transformer: superjson,/*** @see https://trpc.io/docs/v10/error-formatting*/errorFormatter({ shape }) {return shape;},});/*** Create a router* @see https://trpc.io/docs/v10/router*/export const router = t.router;/*** Create an unprotected procedure* @see https://trpc.io/docs/v10/procedures**/export const publicProcedure = t.procedure;/*** @see https://trpc.io/docs/v10/middlewares*/export const middleware = t.middleware;/*** @see https://trpc.io/docs/v10/merging-routers*/export const mergeRouters = t.mergeRouters;
/src/server/trpc.tsts
/*** This is your entry point to setup the root configuration for tRPC on the server.* - `initTRPC` should only be used once per app.* - We export only the functionality that we use so we can enforce which base procedures should be used** Learn how to create protected base procedures and other things below:* @see https://trpc.io/docs/v10/router* @see https://trpc.io/docs/v10/procedures*/import { initTRPC } from '@trpc/server';import superjson from 'superjson';import { Context } from './context';const t = initTRPC.context<Context>().create({/*** @see https://trpc.io/docs/v10/data-transformers*/transformer: superjson,/*** @see https://trpc.io/docs/v10/error-formatting*/errorFormatter({ shape }) {return shape;},});/*** Create a router* @see https://trpc.io/docs/v10/router*/export const router = t.router;/*** Create an unprotected procedure* @see https://trpc.io/docs/v10/procedures**/export const publicProcedure = t.procedure;/*** @see https://trpc.io/docs/v10/middlewares*/export const middleware = t.middleware;/*** @see https://trpc.io/docs/v10/merging-routers*/export const mergeRouters = t.mergeRouters;
Defining routers & procedures
ts
// v9:const appRouter = trpc.router().query('greeting', {input: z.string(),resolve({ input }) {return `hello ${input}!`;},});// v10:const appRouter = router({greeting: publicProcedure.input(z.string()).query(({ input }) => `hello ${input}!`),});
ts
// v9:const appRouter = trpc.router().query('greeting', {input: z.string(),resolve({ input }) {return `hello ${input}!`;},});// v10:const appRouter = router({greeting: publicProcedure.input(z.string()).query(({ input }) => `hello ${input}!`),});
Calling procedures
ts
// v9client.query('greeting', 'KATT');trpc.useQuery(['greeting', 'KATT']);// v10// You can now CMD+click `greeting` to jump straight to your server code.client.greeting.query('KATT');trpc.greeting.useQuery('KATT');
ts
// v9client.query('greeting', 'KATT');trpc.useQuery(['greeting', 'KATT']);// v10// You can now CMD+click `greeting` to jump straight to your server code.client.greeting.query('KATT');trpc.greeting.useQuery('KATT');
Inferring types
v9
ts
// Building multiple complex helper types yourself. Yuck!export type TQuery = keyof AppRouter['_def']['queries'];export type InferQueryInput<TRouteKey extends TQuery> = inferProcedureInput<AppRouter['_def']['queries'][TRouteKey]>;type GreetingInput = InferQueryInput<'greeting'>;
ts
// Building multiple complex helper types yourself. Yuck!export type TQuery = keyof AppRouter['_def']['queries'];export type InferQueryInput<TRouteKey extends TQuery> = inferProcedureInput<AppRouter['_def']['queries'][TRouteKey]>;type GreetingInput = InferQueryInput<'greeting'>;
v10
Inference helpers
ts
// Inference helpers are now shipped out of the box.import type {inferRouterInputs ,inferRouterOutputs } from '@trpc/server';import type {AppRouter } from './server';typeRouterInput =inferRouterInputs <AppRouter >;typeRouterOutput =inferRouterOutputs <AppRouter >;typePostCreateInput =RouterInput ['post']['create'];typePostCreateOutput =RouterOutput ['post']['create'];
ts
// Inference helpers are now shipped out of the box.import type {inferRouterInputs ,inferRouterOutputs } from '@trpc/server';import type {AppRouter } from './server';typeRouterInput =inferRouterInputs <AppRouter >;typeRouterOutput =inferRouterOutputs <AppRouter >;typePostCreateInput =RouterInput ['post']['create'];typePostCreateOutput =RouterOutput ['post']['create'];
See Inferring types for more.
Middlewares
Middlewares are now reusable and can be chained, see the middleware docs for more.
ts
// v9const appRouter = trpc.router().middleware(({ next, ctx }) => {if (!ctx.user) {throw new TRPCError({ code: 'UNAUTHORIZED' });}return next({ctx: {...ctx,user: ctx.user,},});}).query('greeting', {resolve({ input }) {return `hello ${ctx.user.name}!`;},});// v10export const isAuthed = t.middleware(({ next, ctx }) => {if (!ctx.user) {throw new TRPCError({ code: 'UNAUTHORIZED' });}return next({ctx: {// Old context will automatically be spread.// Only modify what's changed.user: ctx.user,},});});// Reusable:const protectedProcedure = t.procedure.use(isAuthed);const appRouter = t.router({greeting: protectedProcedure.query(({ ctx }) => {return `Hello ${ctx.user.name}!`}),});
ts
// v9const appRouter = trpc.router().middleware(({ next, ctx }) => {if (!ctx.user) {throw new TRPCError({ code: 'UNAUTHORIZED' });}return next({ctx: {...ctx,user: ctx.user,},});}).query('greeting', {resolve({ input }) {return `hello ${ctx.user.name}!`;},});// v10export const isAuthed = t.middleware(({ next, ctx }) => {if (!ctx.user) {throw new TRPCError({ code: 'UNAUTHORIZED' });}return next({ctx: {// Old context will automatically be spread.// Only modify what's changed.user: ctx.user,},});});// Reusable:const protectedProcedure = t.procedure.use(isAuthed);const appRouter = t.router({greeting: protectedProcedure.query(({ ctx }) => {return `Hello ${ctx.user.name}!`}),});
Full example with data transformer, OpenAPI metadata, and error formatter
/src/server/trpc.tsts
import { initTRPC } from '@trpc/server';import superjson from 'superjson';// Context is usually inferred,// but we will need it here for this example.interface Context {user?: {id: string;name: string;};}interface Meta {openapi: {enabled: boolean;method: string;path: string;};}export const t = initTRPC.context<Context>().meta<Meta>().create({errorFormatter({ shape, error }) {return {...shape,data: {...shape.data,zodError:error.code === 'BAD_REQUEST' && error.cause instanceof ZodError? error.cause.flatten(): null,},};},transformer: superjson,});
/src/server/trpc.tsts
import { initTRPC } from '@trpc/server';import superjson from 'superjson';// Context is usually inferred,// but we will need it here for this example.interface Context {user?: {id: string;name: string;};}interface Meta {openapi: {enabled: boolean;method: string;path: string;};}export const t = initTRPC.context<Context>().meta<Meta>().create({errorFormatter({ shape, error }) {return {...shape,data: {...shape.data,zodError:error.code === 'BAD_REQUEST' && error.cause instanceof ZodError? error.cause.flatten(): null,},};},transformer: superjson,});
Migrating from v9
We recommend two strategies to start (and finish!) upgrading your codebase today.
Using a codemod
@sachinraja has created an excellent codemod for this major upgrade. Run the script to have 95% of the work done for you in a matter of moments.
- If you use the codemod, you should still do steps 1-3 below to make sure that works for you before doing the full migration.
- Please note that this codemod isn't perfect but will do a lot of the heavy lifting for you.
Using .interop()
Rewriting all of your existing v9 routes today may be too heavy of a lift for you and your team. Instead, let's keep those v9 procedures in place and incrementally adopt v10 by leveraging v10's interop()
method.
1. Enable interop()
on your v9 router
Turning your v9 router into a v10 router only takes 10 characters. Add .interop()
to the end of your v9 router... and you're done with your server code!
src/server/routers/_app.tsdiff
const appRouter = trpc.router<Context>()/* ... */+ .interop();export type AppRouter = typeof appRouter;
src/server/routers/_app.tsdiff
const appRouter = trpc.router<Context>()/* ... */+ .interop();export type AppRouter = typeof appRouter;
There are a few features that are not supported by .interop()
. We expect nearly all of our users to be able to use .interop()
to migrate their server side code in only a few minutes. If you are discovering that .interop()
is not working correctly for you, be sure to check here.
2. Create the t
-object
Now, let's initialize a v10 router so we can start using v10 for any new routes we will write.
src/server/trpc.tsts
import { initTRPC } from '@trpc/server';import superjson from 'superjson';import { Context } from './context';const t = initTRPC.context<Context>().create({// Optional:transformer: superjson,// Optional:errorFormatter({ shape }) {return {...shape,data: {...shape.data,},};},});/*** We recommend only exporting the functionality that we* use so we can enforce which base procedures should be used**/export const router = t.router;export const mergeRouters = t.mergeRouters;export const publicProcedure = t.procedure;
src/server/trpc.tsts
import { initTRPC } from '@trpc/server';import superjson from 'superjson';import { Context } from './context';const t = initTRPC.context<Context>().create({// Optional:transformer: superjson,// Optional:errorFormatter({ shape }) {return {...shape,data: {...shape.data,},};},});/*** We recommend only exporting the functionality that we* use so we can enforce which base procedures should be used**/export const router = t.router;export const mergeRouters = t.mergeRouters;export const publicProcedure = t.procedure;
3. Create a new appRouter
- Rename your old
appRouter
tolegacyRouter
- Create a new app router:
src/server/routers/_app.tsts
import * astrpc from '@trpc/server';import {mergeRouters ,publicProcedure ,router } from './trpc';// Renamed from `appRouter`constlegacyRouter =trpc .router ()/* ... */.interop ();constmainRouter =router ({greeting :publicProcedure .query (() => 'hello from tRPC v10!'),});// Merge v9 router with v10 routerexport constappRouter =mergeRouters (legacyRouter ,mainRouter );export typeAppRouter = typeofappRouter ;
src/server/routers/_app.tsts
import * astrpc from '@trpc/server';import {mergeRouters ,publicProcedure ,router } from './trpc';// Renamed from `appRouter`constlegacyRouter =trpc .router ()/* ... */.interop ();constmainRouter =router ({greeting :publicProcedure .query (() => 'hello from tRPC v10!'),});// Merge v9 router with v10 routerexport constappRouter =mergeRouters (legacyRouter ,mainRouter );export typeAppRouter = typeofappRouter ;
Be careful of using procedures that will end up having the same caller name! You will run into issues if a path in your legacy router matches a path in your new router.
4. Use it in your client
Both sets of procedures will now be available for your client as v10 callers. You will now need to visit your client code to update your callers to the v10 syntax.
ts
// Vanilla JS v10 client caller:client.proxy.greeting.query();// React v10 client caller:trpc.proxy.greeting.useQuery();
ts
// Vanilla JS v10 client caller:client.proxy.greeting.query();// React v10 client caller:trpc.proxy.greeting.useQuery();
Limitations of interop
Subscriptions
We have changed the API of Subscriptions where subscriptions need to return an observable
-instance. See subscriptions docs.
🚧 Feel free to contribute to improve this section
Custom HTTP options
See HTTP-specific options moved from TRPCClient
to links.
Custom Links
In v10, the Links architecture has been completely revamped. Therefore, custom links made for v9 will not work for v10 or while on interop. If you want more information about how to create a custom link for v10, checkout the Links documentation.
Client Package Changes
v10 also brings changes to the client side of your application. After making a few key changes, you'll unlock a few key quality of life changes:
- Jump to server definitions straight from your client
- Rename routers or procedures straight from the client
@trpc/react-query
Renaming of @trpc/react to @trpc/react-query
The @trpc/react
package has been renamed to @trpc/react-query
. This is to reflect the fact that it is a thin wrapper around react-query
, as well as to allow for situations where trpc may be used in react without the @trpc/react-query
package, such as with upcoming React Server Components (RSCs) or with other data fetching library adapters. If you're using @trpc/react
, you'll need to remove it and install @trpc/react-query
instead, as well as update your imports:
diff
- import { createReactQueryHooks } from '@trpc/react';+ import { createReactQueryHooks } from '@trpc/react-query';
diff
- import { createReactQueryHooks } from '@trpc/react';+ import { createReactQueryHooks } from '@trpc/react-query';
Major version upgrade of react-query
We've upgraded peerDependencies
from react-query@^3
to @tanstack/react-query@^4
. Because our client hooks are only a thin wrapper around react-query, we encourage you to visit their migration guide for more details about your new React hooks implementation.
tRPC-specific options on hooks moved to trpc
To avoid collisions and confusion with any built-in react-query
properties, we have moved all of the tRPC options to a property called trpc
. This namespace brings clarity to options that are specific to tRPC and ensures that we won't collide with react-query
in the future.
tsx
// BeforeuseQuery(['post.byId', '1'], {context: {batching: false,},});// After:useQuery(['post.byId', '1'], {trpc: {context: {batching: false,},},});// or:trpc.post.byId.useQuery('1', {trpc: {batching: false,},});
tsx
// BeforeuseQuery(['post.byId', '1'], {context: {batching: false,},});// After:useQuery(['post.byId', '1'], {trpc: {context: {batching: false,},},});// or:trpc.post.byId.useQuery('1', {trpc: {batching: false,},});
Query key changes
If you only use the tRPC provided APIs in your app you will have no problems in
migrating 👍 However if you have been using the tanstack query client directly
to do things like update query data for multiple tRPC generated queries using
queryClient.setQueriesData
you may need to take note!
To allow us to make room for some more advanced features like invalidation across whole routers, we needed to change how we use tanstack query keys under the hood.
We have changed the query keys we use from using a .
joined string for the
procedure path to a sub array of elements. We have also added a distinction
between query
's and infinite
queries when they are placed in the cache. We
have also moved both this query type
and the input into an object with named
properties.
Given the simple router below:
tsx
export const appRouter = router({user: router({byId: publicProcedure.input(z.object({ id: z.number() })).query(({ input }) => ({ user: { id: input.id } })),}),});
tsx
export const appRouter = router({user: router({byId: publicProcedure.input(z.object({ id: z.number() })).query(({ input }) => ({ user: { id: input.id } })),}),});
The query key used for trpc.user.byId.useQuery({ id: 10 })
would change:
- Key in V9:
["user.byId", { id: 10 }]
- Key in v10:
[["user", "byId"],{ input: { id:10 }, type: 'query' }]
The majority of developers won't even notice this change, but for the small
minority that are using the tanstack queryClient
directly to manipulate tRPC
generated queries, they will have to change the key they are filtering on!
@trpc/client
Aborting procedures
In v9, the .cancel()
method was used to abort procedures.
For v10, we have moved to the AbortController Web API to align better with web standards. Instead of calling .cancel()
, you'll give the query an AbortSignal
and call .abort()
on its parent AbortController
.
tsx
const ac = new AbortController();const helloQuery = client.greeting.query('KATT', { signal: ac.signal });// Abortingac.abort();
tsx
const ac = new AbortController();const helloQuery = client.greeting.query('KATT', { signal: ac.signal });// Abortingac.abort();
HTTP-specific options moved from TRPCClient
to links
Previously, HTTP options (like headers) were placed straight onto your createTRPCClient()
. However, since tRPC is technically not tied to HTTP itself, we've moved these from the TRPCClient
to httpLink
and httpBatchLink
.
ts
// Before:import { createTRPCClient } from '@trpc/client';const client = createTRPCClient({url: '...',fetch: myFetchPonyfill,AbortController: myAbortControllerPonyfill,headers() {return {'x-foo': 'bar',};},});// After:import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';const client = createTRPCProxyClient({links: [httpBatchLink({url: '...',fetch: myFetchPonyfill,AbortController: myAbortControllerPonyfill,headers() {return {'x-foo': 'bar',};},})]});
ts
// Before:import { createTRPCClient } from '@trpc/client';const client = createTRPCClient({url: '...',fetch: myFetchPonyfill,AbortController: myAbortControllerPonyfill,headers() {return {'x-foo': 'bar',};},});// After:import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';const client = createTRPCProxyClient({links: [httpBatchLink({url: '...',fetch: myFetchPonyfill,AbortController: myAbortControllerPonyfill,headers() {return {'x-foo': 'bar',};},})]});
This change is also reflected in the @trpc/server
package, where http
related exports were previously exported from the main entrypoint but have now been moved to their own @trpc/server/http
entrypoint.
Extras
Removal of the teardown option
The teardown option has been removed and is no longer available.
createContext
return type
The createContext
function can no longer return either null
or undefined
. If you weren't using a custom context, you'll have to return an empty object:
diff
- createContext: () => null,+ createContext: () => ({}),
diff
- createContext: () => null,+ createContext: () => ({}),
queryClient
is no longer exposed through tRPC context
tRPC is no longer exposing the queryClient
instance through trpc.useContext()
. If you need to use some methods from queryClient
, check if trpc.useContext()
wraps them here. If tRPC doesn't wrap the respective method yet, you can import the queryClient
from @tanstack/react-query
and use it that way:
tsx
import { useQueryClient } from '@tanstack/react-query';const MyComponent = () => {const queryClient = useQueryClient();// ...};
tsx
import { useQueryClient } from '@tanstack/react-query';const MyComponent = () => {const queryClient = useQueryClient();// ...};
Migrate custom error formatters
You will need to move the contents of your formatError()
into your root t
router. See the Error Formatting docs for more.