Skip to main content

· 9 min read
Julius Marminge

The builder-pattern for creating procedures which was introduced in tRPC v10 has been massively appreciated by the community, and many libraries have adopted similar patterns. There's even been coined a term tRPC like XYZ as evidence of the growing popularity of this pattern. In fact, the other day I saw someone wondering if there was a way to write CLI applications with a similar API to tRPC. Sidenote, you can even use tRPC directly to do this. But that's not what we're here to talk about today, we're going to talk about how to use tRPC with server actions from Next.js.

What's a server action?

In case you live under a rock and haven't kept up with the latest React and Next.js features, server actions allows you to write regular functions that are executed on the server, import them on the client and call them just as if they were regular functions. You may think that this sounds similar to tRPC which is true. According to Dan Abramov, server actions are tRPC as a bundler feature:

And this is totally accurate, server actions are similar to tRPC, at the end of the day they're both RPCs. Both allow you to write functions on the backend and call them with full typesafety on the frontend with the network layer abstracted away.

So where does tRPC come in? Why would I need both tRPC and server actions? Server actions is a primitive, and as for all primitives they're quite barebones and thus lacking some fundamental aspects when it comes to building APIs. For any API endpoint that is exposed over the network, you need to validate and authorize requests to ensure your API is not maliciously used. As previously mentioned, tRPC's API is appreciated by the community, so wouldn't it be nice if we could use tRPC to define server actions and utilize all the awesome features that come built-in with tRPC such as input validation, authentication and authorization through middlewares, output validation, data transformers, etc, etc? I think so, so let's dig in.

Defining server actions with tRPC

note

Prerequisites: In order to use server actions, you need to use the Next.js App Router. Additionally, all the tRPC stuff we'll use are only available on tRPC v11, so make sure you're using the beta release channel of tRPC:

npm install @trpc/server@next

Let's start off by initializing tRPC and defining our base server actions procedure. We'll use the experimental_caller method on the procedure builder, which is a new method that allows you to customize the way that the procedure is called when it's invoked as a function. We'll also use the adapter experimental_nextAppDirCaller to make it compatible with Next.js. This adapter will handle cases where the server action is wrapped in useActionState on the client, which changes the call signature of the server action.

We'll also be using a span property as metadata, since there is no ordinary path like when you use a router (user.byId for example). You can use the span property to differentiate procedures, for example during logging or observability.

server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
);
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
);

Next, we'll add some context. Since we wont be hosting a router using a regular HTTP adapter, we won't have any context injected through the createContext method on the adapter. Instead, we'll use a middleware to inject our context. In this example, let's retrieve the current user from the session, and inject it into the context.

server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
});
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
});

Lastly, we'll create a protectedAction procedure that will protect any action from unauthenticated users. If you have an existing middleware that does this you can use that, but I'll define one in-line for this example.

server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
export const protectedAction = serverActionProcedure.use((opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
 
return opts.next({
ctx: {
...opts.ctx,
user: opts.ctx.user, // <-- ensures type is non-nullable
(property) user: User
},
});
});
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
export const protectedAction = serverActionProcedure.use((opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
 
return opts.next({
ctx: {
...opts.ctx,
user: opts.ctx.user, // <-- ensures type is non-nullable
(property) user: User
},
});
});

Alright, let's write an actual server action. Create an _actions.ts file, decorate it with the "use server" directive, and define your action.

app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = protectedAction
.input(
z.object({
title: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// Do something with the input
});
 
// Since we're using the `experimental_caller`,
// our procedure is now just an ordinary function:
createPost;
const createPost: (input: { title: string; }) => Promise<void>
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = protectedAction
.input(
z.object({
title: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// Do something with the input
});
 
// Since we're using the `experimental_caller`,
// our procedure is now just an ordinary function:
createPost;
const createPost: (input: { title: string; }) => Promise<void>

Wow, it's that easy to define a server action that's protected from unauthenticated users, with input validation to protect against attacks such as SQL injections. Let's import this function on the client and call it.

app/post-form.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await createPost({ title });
}}
>
<input type="text" name="title" />
<button type="submit">Create Post</button>
</form>
);
}
app/post-form.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await createPost({ title });
}}
>
<input type="text" name="title" />
<button type="submit">Create Post</button>
</form>
);
}

Going further

Using tRPC builders and it's composable way of defining reusable procedures, we can easily build more complex server actions. Below are some examples:

Observability

You can use @baselime/node-opentelemtry's trpc plugin to add observability in just a few lines of code:

diff
--- server/trpc.ts
+++ server/trpc.ts
+ import { tracing } from '@baselime/node-opentelemetry/trpc';
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: (meta: Meta) => meta.span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export const createPost = protectedAction
+ .meta({ span: 'create-post' })
.input(
z.object({
title: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// Do something with the input
});
diff
--- server/trpc.ts
+++ server/trpc.ts
+ import { tracing } from '@baselime/node-opentelemetry/trpc';
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: (meta: Meta) => meta.span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export const createPost = protectedAction
+ .meta({ span: 'create-post' })
.input(
z.object({
title: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// Do something with the input
});

Checkout the Baselime tRPC Integration for more information. Similar patterns should work for whatever observability playform you're using.

Rate Limiting

You can use a service such as Unkey to rate limit your server actions. Here's an example of a protected server action that uses Unkey to rate limit the number of requests per user:

server/trpc.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
server/trpc.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = rateLimitedAction
.input(
z.object({
postId: z.string(),
content: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
console.log(
`${ctx.user.name} commented on ${input.postId} saying ${input.content}`,
);
});
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = rateLimitedAction
.input(
z.object({
postId: z.string(),
content: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
console.log(
`${ctx.user.name} commented on ${input.postId} saying ${input.content}`,
);
});

Read more on rate limiting your tRPC procedures in this post by the folks over at Unkey.

The possibilities are endless, and I bet you already got a ton of nice utility middlewares you're using in your tRPC applications today. If not, you might found some out there you can npm install!

Wrapping up

Server Actions are by no means a silver bullet. In places that requires more dynamic data, you might want to keep your data in the client-side React Query cache, and do mutations using useMutation instead. That's totally valid. These new primitives should also be easy to incrementally adopt, so you can move individual procedures over from your existing tRPC API to server actions in places where it makes sense to do so. There's no need to rewrite your entire API.

By defining your server actions using tRPC, you can share a lot of the same logic you're using today and choose where you expose the mutation as a server action or as a more traditional mutation. You as a developer have the power to pick what patterns works best for your application. In case you're not using tRPC today, there are some packages (next-safe-action and zsa comes to mind) that let's you define type-safe, input validated server actions worth checking out as well.

If you wanna see an app using this in action, check out Trellix tRPC, an app I made recently utilizing these new primitives.

What do you think? We want your feedback

So, what do you think? Let us know over at Github and help us iterate to get these primitives to a stable state.

There's still some work to be done, especially regarding error handling. Next.js advocates for returning errors, and we'd like to make this as typesafe as possible. Check out this WIP PR by Alex for some early work on this.

Until next time, happy coding!

· 12 min read
Julius Marminge

Ever wondered how tRPC works? Maybe you want to start contributing to the project but you're frightened by the internals? The aim of this post is to familiarize you with the internals of tRPC by writing a minimal client that covers the big parts of how tRPC works.

info

It's recommended that you understand some of the core concepts in TypeScript such as generics, conditional types, the extends keyword and recursion. If you're not familiar with these, I recommend going through Matt Pocock's Beginner TypeScript tutorial to get familiar with these concepts before reading on.

Overview

Let's assume we have a simple tRPC router with three procedures that looks like this:

ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});
ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});

The goal of our client is to mimic this object structure on our client so that we can call procedures like:

ts
const post1 = await client.post.byId.query({ id: '123' });
const post2 = await client.post.byTitle.query({ title: 'Hello world' });
const newPost = await client.post.create.mutate({ title: 'Foo' });
ts
const post1 = await client.post.byId.query({ id: '123' });
const post2 = await client.post.byTitle.query({ title: 'Hello world' });
const newPost = await client.post.create.mutate({ title: 'Foo' });

To do this, tRPC uses a combination of Proxy-objects and some TypeScript magic to augment the object structure with the .query and .mutate methods on them - meaning we actually LIE to you about what you're doing (more on that later) in order to provide an excellent developer experience!

On a high level, what we want to do is to map post.byId.query() to a GET request to our server, and post.create.mutate() to a POST request, and the types should all be propagated from back to front. So, how do we do this?

Implementing a tiny tRPC client

🧙‍♀️ The TypeScript magic

Let's start with the fun TypeScript magic to unlock the awesome autocompletion and typesafety we all know and love from using tRPC.

We'll need to use recursive types so that we can infer arbitrary deep router structures. Also, we know that we want our procedures post.byId and post.create to have the .query and .mutate methods on them respectively - in tRPC, we call this decorating the procedures. In @trpc/server, we have some inference helpers that will infer the input and output types of our procedures with these resolved methods, which we'll use to infer the types for these functions, so let's write some code!

Let's consider what we want to achieve to provide autocompletion on paths as well as inference of the procedures input and output types:

  • If we're on a router, we want to be able to access it's sub-routers and procedures. (we'll get to this in a little bit)
  • If we're on a query procedure, we want to be able to call .query on it.
  • If we're on a mutation procedure, we want to be able to call .mutate on it.
  • If we're trying to access anything else, we want to get a type error indicating that procedure doesn't exist on the backend.

So let's create a type that will do this for us:

ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
mutate: Resolver<TProcedure>;
}
: never;
ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
mutate: Resolver<TProcedure>;
}
: never;

We'll use some of tRPC's built-in inference helpers to infer the input and output types of our procedures to define the Resolver type.

ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 
ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 

Let's try this out on our post.byId procedure:

ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<inferProcedureOutput<QueryProcedure<{ input: { id: string; }; output: Post; }>>>
ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<inferProcedureOutput<QueryProcedure<{ input: { id: string; }; output: Post; }>>>

Nice, that's what we expected - we can now call .query on our procedure and get the correct input and output types inferred!

Finally, we'll create a type that will recursively traverse the router and decorate all procedures along the way:

ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};
ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};

Let's digest this type a bit:

  1. We pass a TRPCRouterRecord to the type as a generic, which is a type containing all the procedures and sub-routers that exists on a tRPC router.
  2. We iterate over the keys of the record, which are the procedure or router names, and do the following:
    • If the key maps to a router, we recursively call the type on that router's procedure record, which will decorate all the procedures in that router. This will provide autocompletion as we traverse the path.
    • If the key maps to a procedure, we decorate the procedure using the DecorateProcedure type we created earlier.
    • If the key doesn't map to a procedure or router, we assign the never type which is like saying "this key doesn't exist" which will cause a type error if we try to access it.

🤯 The Proxy remapping

Now that we got all the types setup, we need to actually implement the functionality which will augment the server's router definition on the client so we can invoke the procedures like normal functions.

We'll first create a helper function for creating recursive proxies - createRecursiveProxy:

info

This is almost the exact implementation used in production, with the exception that we aren't handling some edge cases. See for yourself!

ts
interface ProxyCallbackOptions {
path: string[];
args: unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}
ts
interface ProxyCallbackOptions {
path: string[];
args: unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}

This looks a bit magical, what does this do?

  • The get method handles property accesses such as post.byId. The key is the property name we're accessing, so when we type post our key will be post, and when we type post.byId our key will be byId. The recursive proxy combines all of these keys into a final path, e.g. ["post", "byId", "query"], that we can use to determine the URL we want to send a request to.
  • The apply method is called when we invoke a function on the proxy, such as .query(args). The args is the arguments we pass to the function, so when we call post.byId.query(args) our args will be our input, which we'll provide as query parameters or request body depending on the type of procedure. The createRecursiveProxy takes in a callback function which we'll map the apply to with the path and args.

Below is a visual representation of how the proxy works on the call trpc.post.byId.query({ id: 1 }):

proxy

🧩 Putting it all together

Now that we have this helper and know what it does, let's use it to create our client. We'll provide the createRecursiveProxy a callback that will take the path and args and request the server using fetch. We'll need to add a generic to the function that will accept any tRPC router type (AnyTRPCRouter), and then we'll cast the return type to the DecorateRouterRecord type we created earlier:

ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<TRouter['_def']['record']>;
// ^? provide empty array as path to begin with
ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<TRouter['_def']['record']>;
// ^? provide empty array as path to begin with

Most notably here is that our path is .-separated instead of /. This allows us to have a single API handler on the server which will process all requests, and not one for each procedure. If you're using a framework with filebased routing such as Next.js, you might recognize the catchall /api/trpc/[trpc].ts file which will match all procedure paths.

We also have a TRPCResponse type annotation on the fetch-request. This determines the JSONRPC-compliant response format that the server responds with. You can read more on that here. TL;DR, we get back either a result or an error object, which we can use to determine if the request was successful or not and do appropriate error handling if something went wrong.

And that's it! This is all the code you'll need to call your tRPC procedures on your client as if they were local functions. On the surface, it looks like we're just calling the publicProcedure.query / mutation's resolver function via normal property accesses, but we're actually crossing a network boundary so we can use server-side libraries such as Prisma without leaking database credentials.

Trying it out!

Now, create the client and provide it your server's url and you'll get full autocompletion and type safety when you call your procedures!

ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }
ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }

The full code for the client can be found here, and tests showing the usage here.

Conclusion

I hope you enjoyed this article and learned something about how tRPC works. You should probably not use this to in favor of @trpc/client which is only a couple of KBs bigger - it comes with a lot more flexibility than what we're showcasing here:

  • Query options for abort signals, ssr etc...
  • Links
  • Procedure batching
  • WebSockets / subscriptions
  • Nice error handling
  • Data transformers
  • Edge cases handling like when we don't get a tRPC-compliant response

We also didn't cover much of the server-side of things today, maybe we'll cover that in a future article. If you have any questions, feel free to bug me on Twitter.

· 9 min read
Sachin Raja

As library authors, our goal is to provide the best possible developer experience (DX) for our peers. Reducing time-to-error and providing intuitive APIs removes mental overhead from the minds of developers so that they can focus on what's most important: great end user experience.


It's no secret that TypeScript is the driving force behind how tRPC ships its amazing DX. TypeScript adoption is the modern standard in delivering great JavaScript-based experiences today - but this improved certainty around types does have some tradeoffs.

Today, the TypeScript type checker is prone to becoming slow (although releases like TS 4.9 are promising!). Libraries almost always contain the fanciest TypeScript incantations in your codebase, pressing your TS compiler to its limits. For this reason, library authors like us must be mindful of our contributions to that burden and do our best to keep your IDE working as fast as possible.

Automating library performance

While tRPC was in v9, we began seeing reports from developers that their large tRPC routers were starting to have detrimental effects on their type checker. This was a new experience for tRPC as we saw tremendous adoption during the v9 phase of tRPC's development. With more developers creating larger and larger products with tRPC, some cracks began to show.

Your library may not be slow now, but it's important to keep an eye on performance as your library grows and changes. Automated testing can remove an immense burden from your library authoring (and application building!) by programmatically testing your library code on each commit.

For tRPC, we do our best to ensure this by generating and testing a router with 3,500 procedures and 1,000 routers. But this only tests how far we can push the TS compiler before it breaks and not how long type-checking takes. We test all three pieces of the library (server, vanilla client, and the React client) because they all have different code paths. In the past, we have seen regressions that are isolated to one section of the library and rely on our tests to show us when those unexpected behaviors occur. (We still do want to do more to measure compilation times)

tRPC is not a runtime-heavy library so our performance metrics are centered around type-checking. Therefore, we stay mindful of:

  • Being slow to type-check using tsc
  • Having a large initial load time
  • If the TypeScript language server takes a long time to respond to changes

The last point is one that the tRPC must pay attention to the most. You never want to your developers to have to wait for the language server to update after a change. This is where tRPC must maintain performance so that you can enjoy great DX.

How I found performance opportunities in tRPC

There is always a tradeoff between TypeScript accuracy and compiler performance. Both are important concerns for other developers so we must be extremely conscious of how we write types. Will it be possible for an application to run into severe errors because a certain type is "too loose"? Is the performance gain worth it?

Is there even going to be a meaningful performance gain at all? Great question.

Let's have a look at how to find moments for performance improvements in TypeScript code. We'll visit the process I went through to create PR #2716, resulting in a 59% decrease in TS compilation time.


TypeScript has a built-in tracing tool that can help you find the bottleneck in your types. It's not perfect, but it's the best tool available.

It's ideal to test your library on a real-world app to simulate what your library is doing for real developers. For tRPC, I created a basic T3 app resembling what many of our users work with.

Here's the steps I followed to trace tRPC:

  1. Locally link the library to the example app. This is so you can change your library code and immediately test changes locally.

  2. Run this command in the example app:

    sh
    tsc --generateTrace ./trace --incremental false
    sh
    tsc --generateTrace ./trace --incremental false
  3. You'll be given a trace/trace.json file on your machine. You can open that file in a trace analysis app (I use Perfetto) or chrome://tracing.

This is where things get interesting and we can start to learn about the performance profile of the types in the application. Here's what the first trace looked like: trace bar showing that src/pages/index.ts took 332ms to type-check

A longer bar means more time spent performing that process. I've selected the top green bar for this screenshot, indicating that src/pages/index.ts is the bottleneck. Under the Duration field, you'll see that it took 332ms - an enormous amount of time to spend type-checking! The blue checkVariableDeclaration bar tells us the compiler spent most of its time on one variable. Clicking on that bar will tell us which one it is: trace info showing the variable's position is 275 The pos field reveals the position of the variable in the file's text. Going to that position in src/pages/index.ts reveals that the culprit is utils = trpc.useContext()!

But how could this be? We're just using a simple hook! Let's look at the code:

tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;
tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;

Okay, not much to see here. We only see a single useContext and a query invalidation. Nothing that should be TypeScript heavy at face value, indicating that the problem must be deeper in the stack. Let's look at the types behind this variable:

ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @link https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;
ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @link https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;

Okay, now we have some things to unpack and learn about. Let's figure out what this code is doing first.

We have a recursive type DecoratedProcedureUtilsRecord that walks through all the procedures in the router and "decorates" (adds methods to) them with React Query utilities like invalidateQueries.

In tRPC v10 we still support old v9 routers, but v10 clients cannot call procedures from v9 routers. So for each procedure we check if it's a v9 procedure (extends LegacyV9ProcedureTag) and strip it out if so. It's all a lot of work for TypeScript to do...if it's not lazily evaluated.

Lazy evaluation

The problem here is that TypeScript is evaluating all of this code in the type system, even though it's not used immediately. Our code is only using utils.r49.greeting.invalidate so TypeScript should only need to unwrap the r49 property (a router), then the greeting property (a procedure), and finally the invalidate function for that procedure. No other types are needed in that code and immediately finding the type for every React Query utility method for all your tRPC procedures would unnecessarily slow TypeScript down. TypeScript defers type evaluation of properties on objects until they are directly used, so theoretically our type above should get lazy evaluation...right?

Well, it's not exactly an object. There's actually a type wrapping the entire thing: OmitNeverKeys. This type is a utility that removes keys that have the value never from an object. This is the part where we strip off the v9 procedures so those properties don't show up in Intellisense.

But this creates a huge performance issue. We forced TypeScript to evaluate the values of all types now to check if they are never.

How can we fix this? Let's change our types to do less.

Get lazy

We need to find a way for the v10 API to adapt to the legacy v9 routers more gracefully. New tRPC projects should not suffer from the reduced TypeScript performance of interop mode.

The idea is to rearrange the core types themselves. v9 procedures are different entities than v10 procedures so they shouldn't share the same space in our library code. On the tRPC server side, this means we had some work to do to store the types on different fields in the router instead of a single record field (see the DecoratedProcedureUtilsRecord from above).

We made a change so v9 routers inject their procedures into a legacy field when they are converted to v10 routers.

Old types:

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;

If you recall the DecoratedProcedureUtilsRecord type above, you can see that we attached LegacyV9ProcedureTag here to differentiate between v9 and v10 procedures on the type level and enforce that v9 procedures are not called from v10 clients.

New types:

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;

Now, we can remove OmitNeverKeys because the procedures are pre-sorted so a router's record property type will contain all the v10 procedures and its legacy property type will contain all the v9 procedures. We no longer force TypeScript to fully evaluate the huge DecoratedProcedureUtilsRecord type. We can also remove the filtering forv9procedures with LegacyV9ProcedureTag.

Did it work?

Our new trace shows that the bottleneck has been removed: trace bar showing that src/pages/index.ts took 136ms to type-check

A substantial improvement! Type-checking time went from 332ms to 136ms 🤯! This may not seem like much in the big picture but it's a huge win. 200ms is a small amount once - but think about:

  • how many other TS libraries are in a project
  • how many developers are using tRPC today
  • how many times their types re-evaluate in a work session

That's a lot of 200ms adding up to a very big number.

We're always looking for more opportunities to improve the experience of TypeScript developers, whether it's with tRPC or a TS-based problem to solve in another project. @ me on Twitter if you want to talk TypeScript.

Thanks to Anthony Shew for helping write this post and to Alex for reviewing!

· 4 min read
Alex / KATT 🐱

tRPC provides a great developer experience by enforcing tight, full-stack type bindings through the power of TypeScript. No API contract drift, no code generation.

Since our last major version release in August 2021, the tRPC community has seen substantial growth:

Today, we're launching tRPC v10. We're excited to share that v10 is already being used in production by many large TypeScript projects. This official release announces general availability to the wider community.

For new projects, you can get up and running with an example application to learn about tRPC v10. For projects that were already enjoying tRPC v9, visit the v10 migration guide.

Overview of changes

v10 is tRPC's biggest release ever. This is the first time we've made any fundamental changes to the structure of tRPC and we believe these changes unlock new possibilities for fast-moving teams working on cutting edge applications.

Improved developer experience

tRPC v10 embraces your IDE. We want to unify your types - but we've also brought together your frontend, backend, and editing experience in this version.

With v10, you can:

  • Use "Go to Definition" to jump straight from your frontend consumer to your backend procedure
  • Use "Rename Symbol" to give a new name to an input argument or procedure across your whole application
  • Infer types more easily for when you'd like to use your tRPC types in your application manually

Powerful backend framework

In v10, we've revisited the syntax for how you define your backend procedures, opening up more opportunities to bring in your desired logic in healthy ways. This version of tRPC features:

Massively improved TypeScript performance

TypeScript enables developers to do incredible things - but it can come at a cost. Many of the techniques we use to keep your types tight are heavy work on the TypeScript compiler. We heard community feedback that the largest applications using tRPC v9 were beginning to suffer from decreased performance in developers' IDEs as a result of this compiler pressure.

Our goal is to enhance the developer experience for applications of all sizes. In v10, we've dramatically improved TypeScript performance (especially with TS incremental compilation) so that your editor stays snappy.

Incremental migration

We've also put in a lot of work to make the migration experience as straightforward as possible, including an interop() method that allows (almost) full backward compatibility with v9 routers. Visit the migration guide for more information.

Sachin from the core team has also made a codemod that can do much of the heavy lifting of the migration for you.

A growing ecosystem

A rich set of sub-libraries is continuing to form around tRPC. Here are a few examples:

For more plugins, examples, and adapters, visit the Awesome tRPC collection.

Thank you!

The core team and I want you to know: we're just getting started. We're already busy experimenting with React Server Components and Next.js 13.

I also want to give a huuuge shoutout to Sachin, Julius, James, Ahmed, Chris, Theo, Anthony, and all the contributors who helped make this release possible.

Thanks for using and supporting tRPC.


· 5 min read
Alex / KATT 🐱

I'm Alex, or "KATT" on GitHub, and I want to tell you about a library called tRPC. I've not published any articles about, so I'm just writing this intro to get the ball rolling (but we have already somehow reached >530 🌟 on GitHub). Expect articles & video intros to come! If you want to stay up-to-date or want to ask questions, you can follow me on Twitter at @alexdotjs.

In short - tRPC gives you end-to-end type safety from your (node-)server to your client, without even declaring types. All you do on the backend is that you return data in a function and on the frontend you use said data based on the endpoint name.

This is how it can look like when doing a tRPC endpoint & client call: Alt Text

I have made a library for React (@trpc/react) that sits on top of the great react-query, but the client library (@trpc/client) works without React (if you want to build a specific Svelte/Vue/Angular/[..] lib, please reach out!)

There's no code generation involved & you can pretty easily add it to your existing Next.js/CRA/Express project.

Example

Here's an example of a tRPC procedure (aka endpoint) called hello that takes a string argument.

tsx
const appRouter = trpc.router().query('hello', {
input: z.string().optional(),
resolve: ({ input }) => {
return {
text: `hello ${input ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;
tsx
const appRouter = trpc.router().query('hello', {
input: z.string().optional(),
resolve: ({ input }) => {
return {
text: `hello ${input ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;

And here's a type safe client using said data:

tsx
import type { AppRouter } from './server';
async function main() {
const client = createTRPCClient<AppRouter>({
url: `http://localhost:2022`,
});
const result = await client.query('hello', '@alexdotjs');
console.log(result); // --> { text: "hello @alexdotjs" }
}
main();
tsx
import type { AppRouter } from './server';
async function main() {
const client = createTRPCClient<AppRouter>({
url: `http://localhost:2022`,
});
const result = await client.query('hello', '@alexdotjs');
console.log(result); // --> { text: "hello @alexdotjs" }
}
main();

That's all you need to get type safety! The result is type inferred from what the backend returns in the function. The data from input is also inferred from the return of the validator, so the data is safe to use straight up - actually, you have to pass the input data through a validator (& tRPC works out-of-the-box with zod/yup/custom validators).

Here's a CodeSandbox link where you can play with the example above: https://githubbox.com/trpc/trpc/tree/next/examples/standalone-server (have a look at the terminal output rather than the preview!)

Wat? I'm importing code from my backend to my client? - No, you're actually not

Even though it might look like it, no code is shared from the server to the client; TypeScript's import type "[..] only imports declarations to be used for type annotations and declarations. It always gets fully erased, so there’s no remnant of it at runtime." - a feature added in TypeScript 3.8 - see TypeScript docs.

There's no code generation involved, you can this to your app today as long as you have a way to share types from the server to the client (hopefully you're using a monorepo already).

But we're only getting started!

I mentioned before that there's a React-library, the way to use the data above in React you do:

tsx
const { data } = trpc.useQuery(['hello', '@alexdotjs']);
tsx
const { data } = trpc.useQuery(['hello', '@alexdotjs']);

.. and you'll get type safe data on the client.

You can add tRPC today with your existing brownfield project (got adapters for Express/Next.js) & it works fine with CRA and should work with React Native as well. It is not even tied to React, so if you want to do a Svelte or Vue lib, please get in touch with me.

What about mutating data?

Mutations are as simple to do as queries, they're actually the same underneath, but are just exposed differently as syntactic sugar and produce a HTTP POST rather than a GET request.

Here's a little more complicated example using a database, taken from our TodoMVC example at todomvc.trpc.io / https://github.com/trpc/trpc/tree/next/examples/next-prisma-todomvc

tsx
const todoRouter = createRouter().mutation('add', {
input: z.object({
id: z.string().uuid(),
data: z.object({
completed: z.boolean().optional(),
text: z.string().min(1).optional(),
}),
}),
async resolve({ ctx, input }) {
const { id, data } = input;
const todo = await ctx.task.update({
where: { id },
data,
});
return todo;
},
});
tsx
const todoRouter = createRouter().mutation('add', {
input: z.object({
id: z.string().uuid(),
data: z.object({
completed: z.boolean().optional(),
text: z.string().min(1).optional(),
}),
}),
async resolve({ ctx, input }) {
const { id, data } = input;
const todo = await ctx.task.update({
where: { id },
data,
});
return todo;
},
});

And the React usage looks like this:

tsx
const addTask = trpc.useMutation('todos.add');
return (
<>
<input
placeholder="What needs to be done?"
onKeyDown={(e) => {
const text = e.currentTarget.value.trim();
if (e.key === 'Enter' && text) {
addTask.mutate({ text });
e.currentTarget.value = '';
}
}}
/>
</>
)
tsx
const addTask = trpc.useMutation('todos.add');
return (
<>
<input
placeholder="What needs to be done?"
onKeyDown={(e) => {
const text = e.currentTarget.value.trim();
if (e.key === 'Enter' && text) {
addTask.mutate({ text });
e.currentTarget.value = '';
}
}}
/>
</>
)

End, for now.

Anyway, as I said, I just wanted to get the ball rolling. There's a lot more things:

  • Creating context for incoming requests for user-specific data that are dependency injected into the resolvers - link
  • Middleware support for routers - link
  • Merging routers (you probably don't want all your backend data in one file) - link
  • Simplest server-side rendering you've ever seen in React-land using our @trpc/next adapter - link
  • Type-safe error formatting - link
  • Data transformers (use Date/Map/Set objects across the wire) - link
  • Helpers for React Query

If you want to get started there's a few examples in the Getting Started for Next.js.

Follow me on Twitter for updates!