Exploring tRPC as an alternative to popular API design patterns

Kamil Pyszkowski's pictureKamil Pyszkowski
13 mins

It's been 4 years since tRPC was officially released. I need to admit it really drawn my attention. It seems like the perfect balance between REST and GraphQL - and it's imperfections. Like the golden mean of API design. This article is my journey of exploring tRPC and how it can be used as an alternative to REST API. I took a peek at the history of RPC in general, compared tRPC against popular API design patterns and finally implemented a simple monorepo with React and Hono.

Brief history of RPC and the rise of tRPC

RPC (Remote Procedure Call) is essentially a protocol that allows to call functions on one computer from another. It's been around since the 80s and has been used in various forms. One of the most popular modern implementations of RPC is gRPC, which is a high-performance, open-source universal RPC framework. Even though gRPC is implemented in many languages, including JavaScript, it's not the most developer-friendly solution. It's quite verbose and requires a lot of boilerplate code to implement.

The response to this issue was the rise of tRPC (TypeScript RPC) - a modern, type-safe RPC framework for TypeScript. It works both with classic monorepos and monolithic applications such as Next.js. It's a perfect solution for TypeScript driven full-stack teams fulfilling gaps between frontend and backend.

Comparing tRPC against REST API

REST API is the most popular API design pattern. It's simple, stateless, and cacheable. It's easy to understand and implement. However, it has its limitations. One of the biggest issues with REST API is over-fetching and under-fetching. It's hard to design a REST API that fits all the needs of the frontend causing friction between frontend and backend implementations.

This is where tRPC shines. Just like in GraphQL you can precisely define the shape of data you'd like to receive from server. Also it's even easier to implement than REST API. It takes away the hussle of HTTP implementation details and allows to focus on the business logic. It handles errors, caching, and even serialization out of the box. You don't need to bother with choosing correct HTTP method nor explicitly validate each payload attached to the request. It's all handled automatically for you. Also scales well with the project size which is not that trivial with REST APIs.

What about GraphQL?

GraphQL looked like the perfect solution to REST API problems. It solves the over-fetching and under-fetching issues by allowing to query only the data you need. However, it has its own limitations. Frontend-wise the association between the backend is more like illusion. There is a schema that serves as a source of truth. However it needs to be updated with every change in the backend. Comparing to tRPC the type definition is resolved on the runtime which makes it more flexible and easier to maintain. It gives an impression of a native association between frontend and backend.

What I don't like about the GraphQL is the need of context switching when it comes to write queries. With tRPC you stay in the TypeScript land. Your code editor will help you with autocompletion providing first class developer experience.

One of the major pros of GraphQL is it's ability to self-document. It exposes an interactive playground where you can explore the schema and test queries. tRPC doesn't fall behind in this aspect. Even though it's pretty minimal in it's core - there is a variety of extensions that can be used to achieve similar functionality. The one I like the most is the OpenAPI standard integration.

Concepts and vocabulary

Before we dive into the code let's get familiar with the vocabulary used around tRPC:

  • Procedure - a function that can be called remotely. It can be public or protected. It can take the input, process it and return the output.
  • Query - a kind of procedure that doesn't change the state of the server, gets the data.
  • Mutation - a kind of procedure that changes the state of the server, modifies the data.
  • Subscription - a kind of procedure that allows to listen to the server events. It creates persistent connection and listens to changes.
  • Router - a collection of procedures. It's a place where all the procedures of given namespace are defined.
  • Context - a place where shared data between procedures is stored. It's passed to all the procedures. Usually the place where database or service handlers are instantiated.
  • Middleware - a function that can run before or after the procedure. It can modify the context.
  • Validator - a function that validates the input and/or the output of the procedure. It can be used to ensure the data is in the correct shape.

Creating the server

Are you hyped to try tRPC out? Let's get it done! I've prepared a simple monorepo with React and Hono. I will take you through all the key steps to set up the server and client.

Hono integrates tRPC with the use of middleware. Besides standard Hono setup you will need the following dependencies:

Initializing the tRPC

First let's initialize the tRPC. You can export whole t object if you wish. In fact you will need only procedure and router so to keep it minimal I've decided to export them separately. You can always export anything else you need.

1import { initTRPC } from '@trpc/server'
2import superjson from 'superjson'
3
4const t = initTRPC.create({
5 transformer: superjson,
6})
7
8export const publicProcedure = t.procedure
9export const createRouter = t.router

Pay attention to the transformer property. That's where you hook up the Superjson. It will automatically serialize and deserialize the payloads for you. If you use it also on the client the process will be bidirectional.

Creating the router

The next step is to create the router. It's a place where you define all the procedures. To do so you just pass the object where keys are the procedure names and values are the procedures themselves. The procedure factory uses the pattern of chaining.

1const TASKS = [
2 {
3 id: 1,
4 title: 'Buy milk',
5 description: 'Remember to buy a lactose-free one',
6 },
7 { id: 2, title: 'Walk the dog', description: "Don't forget about the leash" },
8 { id: 3, title: 'Water the plants', description: 'Use the watering can' },
9]
10
11const tasksRouter = createRouter({
12 getAll: publicProcedure.query(() => {
13 return TASKS
14 }),
15
16 create: publicProcedure
17 .input(
18 z.object({
19 title: z.string(),
20 description: z.string().optional(),
21 }),
22 )
23 .mutation(({ input }) => {
24 const { title, description } = input
25 const newTask = { id: TASKS.length + 1, title, description }
26 TASKS.push(newTask)
27 return newTask
28 }),
29
30 deleteById: publicProcedure
31 .input(
32 z.object({
33 id: z.number(),
34 }),
35 )
36 .mutation(({ input }) => {
37 const { id } = input
38 const taskIndex = TASKS.findIndex((task) => task.id === id)
39 const [deletedTask] = TASKS.splice(taskIndex, 1)
40 return deletedTask
41 }),
42})

For the sake of simplicity I've used an in-memory array to store the tasks. I introduced three procedures: getAll, create, and deleteById:

  • getAll - returns all the tasks,
  • create - accepts a title and an optional description creates a new task and returns it,
  • deleteById - accepts an id of the task to delete and returns the deleted task.

As you can see, procedures are defined in a declarative way. I like the fact here that one follows the other eg. the input is followed by the mutation so you can naturally expect the mutation to use the input. As you can see it's easy to reason about procedures and deduce what they do.

Creating the app

The last step is to create the server and hook it up with the middleware. Before you do that you need to create the root router that aggregates all the routers. It's important to remember about exporting the type of the router. It will be used in the client to feed type definitions.

1const app = new Hono()
2
3const appRouter = createRouter({
4 tasks,
5})
6
7app.use(cors())
8
9app.use(
10 trpcServer({
11 endpoint: '/api/trpc',
12 router: appRouter,
13 }),
14)
15
16export default app
17export type AppRouter = typeof appRouter

Besides the endpoint and router you can define the global error handling and eg. configure Sentry using onError handler. With the use of createContext you can also define context that will be passed to all the procedures. It's a good place to instantiate the database connection or additional services like AWS S3 client.

REST endpoints

In case you need to use the API in an environment any other than TypeScript you can access Procedures via REST endpoints it automatically exposes. You can find them under the /<prefix>/<subroute>.<procedureName>.

For example the getAll procedure from the tasksRouter will be available under the /api/trpc/tasks.getAll endpoint. It's useful when you need to access the API from eg. Bash scripts.

Creating the client

If the concept of procedures and mutations sounds familiar in your frontend venture - you are right! In fact it's the same concept used in the Tanstack React Query - the popular library for managing remote data in React. It's also the core of the React tRPC integration. It means with minimal setup you can get all the goodies such as caching, suspense, error handling, batching, optimistic updates and many more. Let's make it happen!

Before you start you need to install the following dependencies:

Initializing the client and React provider

First let's initialize the tRPC client. The createTRPCReact function is a generic function that accepts the type of the router. That's how the client is natively aware of changes on the server. The function will create the integration layer for React featuring scoped Tanstack React Query hooks, the provider and scoped utlity functions.

Next create the TRPCProvider which is the hybrid of the trpc.Provider you just created and QueryClientProvider. It's also the place where you instantiate the queryClient and trpcClient.

Worth mentioning is the httpBatchLink that is used to batch the requests. In will give your client superpowers of marging multiple queries into one request. It really reduces the network overhead and improves the performance. If you don't need batching you can use httpLink instead.

Also pay attention to the transformer. As I mentioned earlier if you use it on the client as well the serialization and deserialization process will be bidirectional so you won't need to struggle with manually handling Dates or BigInts.

1const trpc = createTRPCReact<AppRouter>()
2
3function TRPCProvider({ children }: PropsWithChildren) {
4 const [queryClient] = useState(() => new QueryClient())
5 const [trpcClient] = useState(() =>
6 trpc.createClient({
7 links: [
8 httpBatchLink({
9 url: env.TRPC_API_URL,
10 transformer: superjson,
11 }),
12 ],
13 }),
14 )
15
16 return (
17 <trpc.Provider
18 client={trpcClient}
19 queryClient={queryClient}
20 >
21 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
22 </trpc.Provider>
23 )
24}

With the TRPCProvider all you need to do is wrap your application with it. It will allow you to use tRPC scoped hooks and utilities.

Consuming procedures

As I mentioned if you are familiar with Tanstack React Query you will feel like home. The trpc object you created is a collection of scoped hooks and utilities. You can use them to query and mutate. Also handle invaliation and trigger refetches.

Personally I'd like to create custom hooks per subrouter/domain - considering you keep it relatively granular. It's a neat way to enclose business logic into reusable methods.

1function useTasks() {
2 const { tasks: tasksUtils } = trpc.useUtils()
3
4 const { data: tasks, isLoading, error } = trpc.tasks.getAll.useQuery()
5
6 const { mutate: createTask, isPending: isCreatingTask } =
7 trpc.tasks.create.useMutation({
8 onSuccess: () => tasksUtils.invalidate(),
9 })
10
11 const { mutate: deleteTask } = trpc.tasks.deleteById.useMutation({
12 onSuccess: ([removedTask]) => {
13 tasksUtils.getAll.setData(undefined, (cachedTasks) =>
14 cachedTasks?.filter((task) => task.id !== removedTask.id),
15 )
16 },
17 })
18
19 return {
20 tasks,
21 isLoading,
22 error,
23 createTask,
24 deleteTask,
25 isCreatingTask,
26 }
27}
28
29export default useTasks

The fact you wrapped your app with the TRPCProvider allows you to use the hook in any place of your application. It also guarantees the data consistency and caching. Each method is type safe and raises linter errors in case of any change in the API. If linter is the part of your CI/CD pipeline it will prevent you from deploying the broken code.

You can try it yourself. Just go to the tasks subrouter and rename some procedure or change the input shape. You will see the linter will raise an error in the place where you use the procedure.

Conclusion

tRPC is a great alternative to REST API and GraphQL. It solves major issues you can face working with REST API or GraphQL. It significantly improves the developer experience and allows to focus on the business logic rather than struggling with repetitive chores such as choosing the correct HTTP method, handling errors, payload and output validation, etc. It fills the gap between frontend and backend making traversing between the two a breeze. It gives impression of single codebase by end-to-end type safety and easy navigation through code with features like Go to Definition.

I hope you found this article helpful and that it added value to your learning journey. I'd love to hear your thoughts, feedback, or questions — feel free to reach out via email at . If you enjoyed this writing, take a moment to explore other articles. Don't forget to check back soon for fresh insights and updates!

See you around!
— Kamil

Fri, Jun 5, 2026, 1:55:51 AM
Genuinely crafted in Poland © 2026