Skip to main content
Version: 10.x

Migrate from v9 to v10

Is v10 ready for production?

tRPC v10 is ready for production and can safely be used today.

v10 is still a beta and will be rapidly changing as we continue to work through community feedback.

v10-beta will be different from v10 itself. This doc is written for migrating from v9 to v10. You will need to find your own path for migrating from v10-beta 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.ts
ts
import { initTRPC } from '@trpc/server';
// You may rename the `t` variable to whatever you prefer.
// Just make sure you initialize your root variable once per application.
export const t = initTRPC.create();
/src/server/trpc.ts
ts
import { initTRPC } from '@trpc/server';
// You may rename the `t` variable to whatever you prefer.
// Just make sure you initialize your root variable once per application.
export const t = initTRPC.create();
Defining routers & procedures
ts
// v9:
const appRouter = trpc.router()
.query('greeting', {
input: z.string(),
resolve({ input }) {
return `hello ${input}!`;
},
});
// v10:
const appRouter = t.router({
greeting: t.procedure
.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 = t.router({
greeting: t.procedure
.input(z.string())
.query(({ input }) => `hello ${input}!`),
});
Calling procedures
ts
// v9
client.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
// v9
client.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

ts
// The same helper is shipped out of the box.
type GreetingInput = inferProcedureInput<AppRouter['greeting']>;
ts
// The same helper is shipped out of the box.
type GreetingInput = inferProcedureInput<AppRouter['greeting']>;

v10 inference helper

ts
import type { GetInferenceHelpers } from '@trpc/server';
import type { AppRouter } from './server';
 
export type AppRouterTypes = GetInferenceHelpers<AppRouter>;
 
type PostCreate = AppRouterTypes['post']['create'];
 
type PostCreateInput = PostCreate['input'];
type PostCreateInput = { title: string; text: string; }
type PostCreateOutput = PostCreate['output'];
type PostCreateOutput = { title: string; text: string; id: string; }
ts
import type { GetInferenceHelpers } from '@trpc/server';
import type { AppRouter } from './server';
 
export type AppRouterTypes = GetInferenceHelpers<AppRouter>;
 
type PostCreate = AppRouterTypes['post']['create'];
 
type PostCreateInput = PostCreate['input'];
type PostCreateInput = { title: string; text: string; }
type PostCreateOutput = PostCreate['output'];
type PostCreateOutput = { title: string; text: string; id: string; }

See Inferring types for more.

Middlewares

Middlewares are now reusable and can be chained, see the middleware docs for more.

ts
// v9
const 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}!`;
},
});
// v10
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 authedProcedure = t.procedure.use(isAuthed);
const appRouter = t.router({
greeting: authedProcedure.query(({ ctx }) => {
return `Hello ${ctx.user.name}!`
}),
});
ts
// v9
const 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}!`;
},
});
// v10
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 authedProcedure = t.procedure.use(isAuthed);
const appRouter = t.router({
greeting: authedProcedure.query(({ ctx }) => {
return `Hello ${ctx.user.name}!`
}),
});
Full example with data transformer, OpenAPI metadata, and error formatter
/src/server/trpc.ts
ts
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.ts
ts
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

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.ts
diff
const appRouter = trpc
.router<Context>()
/* ... */
+ .interop();
export type AppRouter = typeof appRouter;
src/server/routers/_app.ts
diff
const appRouter = trpc
.router<Context>()
/* ... */
+ .interop();
export type AppRouter = typeof appRouter;
info

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.ts
ts
import superjson from 'superjson';
import { Context } from './context';
export const t = initTRPC.context<Context>().create({
// Optional:
transformer: superjson,
// Optional:
errorFormatter({ shape }) {
return {
...shape,
data: {
...shape.data,
},
};
},
});
src/server/trpc.ts
ts
import superjson from 'superjson';
import { Context } from './context';
export const t = initTRPC.context<Context>().create({
// Optional:
transformer: superjson,
// Optional:
errorFormatter({ shape }) {
return {
...shape,
data: {
...shape.data,
},
};
},
});

3. Create a new appRouter

  1. Rename your old appRouter to legacyRouter
  2. Create a new app router:
src/server/routers/_app.ts
ts
import * as trpc from '@trpc/server';
import { t } from './trpc';
 
// Renamed from `appRouter`
const legacyRouter = trpc
.router()
/* ... */
.interop();
 
const mainRouter = t.router({
greeting: t.procedure.query(() => 'hello from tRPC v10!'),
});
 
// Merge v9 router with v10 router
export const appRouter = t.mergeRouters(legacyRouter, mainRouter);
 
export type AppRouter = typeof appRouter;
src/server/routers/_app.ts
ts
import * as trpc from '@trpc/server';
import { t } from './trpc';
 
// Renamed from `appRouter`
const legacyRouter = trpc
.router()
/* ... */
.interop();
 
const mainRouter = t.router({
greeting: t.procedure.query(() => 'hello from tRPC v10!'),
});
 
// Merge v9 router with v10 router
export const appRouter = t.mergeRouters(legacyRouter, mainRouter);
 
export type AppRouter = typeof appRouter;
tip

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.

See the Links documentation.

🚧 Feel free to contribute to improve this section

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

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 around 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
// Before
useQuery(['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
// Before
useQuery(['post.byId', '1'], {
context: {
batching: false,
},
});
// After:
useQuery(['post.byId', '1'], {
trpc: {
context: {
batching: false,
},
},
});
// or:
trpc.post.byId.useQuery('1', {
trpc: {
batching: false,
},
});

@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 });
// Aborting
ac.abort();
tsx
const ac = new AbortController();
const helloQuery = client.greeting.query('KATT', { signal: ac.signal });
// Aborting
ac.abort();

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',
};
},
})
]
});

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: () => ({}),

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.