Lets talk tRPC

What's tRPC and why i fell in love at first sight. Lets setup tRPC for NextJs

#WebDev#typescript
1 Jul 2025
Lets talk tRPC

If you're building a fullstack app using any javascript framework with typescript, there is a hight chance that you had issues with keeping your API types sync between the frontend and backend.

If you don't use typescript, start using and come back here later.


What exactly is tRPC

Google says:

tRPC, which stands for TypeScript Remote Procedure Call, is a library that simplifies building type-safe APIs for TypeScript applications. It allows developers to define API endpoints on the backend and seamlessly access them from the frontend with full type safety, eliminating the need for manual schema definitions, code generation, or runtime type checks.

tRPC stands for typescript Remote Procedure Call, so its a remote procedure call built for typescript.

What is RPC - Remote procedure call

A normal REST api looks something like this -- the client hits the pre defined route with a request.

for example:

GET https://api.calc.me/api/post?id=123

On the server, there's a handler which will read the query params from the request ( id=123 here), validates the input, hits the DB and returns a response.

Unlike REST or GraphQL, where you interact with the resources via URL(s), in a remote procedure call you are directly calling the procedures (functions).

for example:

getPostById(123)

Here getPostById is a procedure (function) defined on the server which expects id: number as an arguement and return a post: Post - a predefined type.

Think of it as normal function calling - but actual execution happens on a remote server.


how tRPC implements this

With tRPC, these server procedures are available on the client - fully typed and inferred.

No need to manually define request/response schemas.

just define a functoin on the server:

// server const appRouter = router({ getPostById: publicProcedure .input(z.number()) .query(({ input }) => db.posts.findById(input)), });

and you can call this function on the client:

const { data } = trpc.getPostById.useQuery(123);

and that's it, the data you get is of type Post that you defined in the db schema. also if you try to pass anything other than a number to the getPostById (like a string), TypeScript + Zod + tRPC will immediately throw an error at compile time.

development speed ++

but how does it exactly work?

How can we call a procedure which is located in a server from the client?

Deep down tRPC works over HTTP, its just a wrapper around HTTP to give you a type safe api development experience.

When you call a procedure like:

trpc.getPostById.useQuery(123)
  1. you define the procedure on the server like:
const appRouter = router({ getPostById: publicProcedure .input(z.number()) .query(({ input }) => db.posts.findById(input)), });
  1. tRPC exposes these procedures over HTTP as API endpoints (lets say: /api/trpc/getPostById), but tRPC handles all this bu itself, we don't have to worry about all this.

  2. On the client, tRPC generate an Object (trpc in this case) that mimics the server-side router.

When we call the procedure

it's actually:

and since we have the trpc object on the client side also - it provides the returned data to be fully typed.

tRPC acts as a type-safe middleman between the server and client, making the development easy and more robust, even though we are making REST calls under the hood.

Benifits of tRPC

from the client to the server and back to client, types are automatically inferred.

you define the input and output types - tRPC and typescript handles the rest.

// input: z.number() -> output: Post

if there is any mismatch between types of input or output you'll immediately get a type error.

Unlike GraphQL or gRPC, you don't need to create sceham defination files (.graphql or .proto for gRPC)

Everything is native TypeScript. API as function.

Available for almost every modern typescript framework.

Be it express, fastify, nextjs, vite, remix etc.

trpc client integrate seamlessly with Tanstack Query and provieds:

and more...

you call your api like:

const { data, isLoading, error } = trpc.getPostById.useQuery(postId);

The bad part

Let's setup tRPC for NextJs

tRPC integrates seamlessly with nextjs, and i would recommend using it in fullstack monorepos.

1. Install dependencies:

bun add @trpc/server @trpc/client @trpc/react-query @trpc/next zod superjson @tanstack/react-query

2. initialize the tRPC server

in server/trpc.ts

import { initTRPC } from "@trpc/server"; import superjson from "superjson"; export const t = initTRPC.create({ transformer: superjson });

superjson is used to serialize javascript expressions to a superset of JSON (optional).

3. create a router

in server/api/router.ts

import { t } from "../trpc"; import { z } from "zod"; export const appRouter = t.router({ test: t.procedure .input(z.string()) .query(({input}) => `hello ${input}`) }); export type AppRouter = typeof appRouter

4. create a API handler (backend endpoint)

This is the endpoint where the actual client server interaction will happen via HTTP.

in app/api/trpc/[trpc].ts

import { createNextApiHandler } from '@trpc/server/adapters/next'; import { appRouter } from '@/server/api/router'; const handler = (req: Request) => { return createNextApiHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext: () => ({}) // Add auth/db if needed }) } export { handler as GET, handler as POST }

5. setup tRPC client

in utils/trpc.ts

import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@/server/api/router'; export const trpc = createTRPCReact<AppRouter>();

6. wrap your app with trpc.provider and QueryClientProvider

create a TRPCProvider.tsx

'use client'; import { getFetch, httpBatchLink, loggerLink } from "@trpc/client"; import superjson from "superjson"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; import { trpc } from "@/util/trpc" // client side trpc const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, } } }) export default function TrpcProviders({ children, }: { children: React.ReactNode; }) { const url = process.env.NEXT_PUBLIC_APP_DOMAIN && !process.env.NEXT_PUBLIC_APP_DOMAIN.includes("localhost") ? `https://www.${process.env.NEXT_PUBLIC_APP_DOMAIN}/api/trpc/` : "http://localhost:3000/api/trpc/"; const [trpcClient] = useState(() => trpc.createClient({ links: [ loggerLink({ enabled: () => true }), httpBatchLink({ url, fetch: (input, init?) => { const fetch = getFetch(); return fetch(input, { ...init, credentials: "include" }); }, transformer: superjson }) ] }) ); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </trpc.Provider> ) }

wrap your app with this TRPCProvider - in app/layout.tsx

import type { Metadata } from "next"; import "./globals.css"; import TrpcProviders from "@/components/providers/TrpcProvider"; export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body> <TrpcProviders> {children} </TrpcProviders> </body> </html> ); }
and that's it your app is ready to use tRPC

Use a procedure in your component

'use client' import { trpc } from "@utils/trpc" export default const Home() { const { data, isLoading, error } = trpc.test.useQuery("calc") // will return "hello calc" return ( <p> {data} </p> ) }

you can define more procedures in the appRouter located at server/api/router.ts

Final thougts

tRPC just clicked for me, maybe it won't for you, but it's definately worth a try.

If you are working in a TypeScript monorepo, tRPC is the way to go.

Thanks for reading!

If you read this far, get a job you no-lifer (love you).

These are all my personal opinions and beliefs. If you find something wrong — please don’t tell me. Let me live in my own bubble.

Until next time.