Skip to main content

Intro to tRPC

· 7 min read
Jason Walsh



tRPC and Zod drastically sped up development for a product I've been building called Mangrove and reduced regressions when refactoring code.

This article illustrates how to implement tRPC and query procedures with Express and React.

  • Zod will be discussed in a separate article
  • tRPC Mutations will be discussed in a separate article

About Mangrove


From the homepage of tRPC's site:

Move Fast and Break Nothing. End-to-end typesafe APIs made easy.

We're going to:

  1. Connect tRPC to Express
  2. Connect tRPC to React
  3. Look at a simple query procedure

First let's start with package.json.


"dependencies": {
"@tanstack/react-query": "^4.29.15",
"@trpc/client": "^10.38.5",
"@trpc/react-query": "^10.38.5",
"@trpc/server": "^10.38.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"zod": "^3.22.2"

tRPC and Express

From path/to/backend/trpc-router.ts

// From package.json; adapter that wires up `tRPC` to `express`
import * as trpcExpress from '@trpc/server/adapters/express';

// Our data entities
import clients from 'path/to/backend/procedures/clients';

Defines vars you expect to exist in your procedures or middlewares,
like IP Addresses, additional pre-loaded data, etc.

This is just the context definition.
Middlewares are responsible for setting the context vars at runtime.

(`req` and `res` are injected via the adapter)
const createContext = ({ req, res }: CreateExpressContextOptions) => {
return {
user: req.user as null | User,

// Initialization of tRPC backend, only initialize once
const t = initTRPC.context<typeof createContext>().create();
const router = t.router;

// Default procedure type for unauthenticated requests like `Forgot Password`
export const publicProcedure = t.procedure;

// Skipping the implementation details of `isAuthed` for brevity
// This procedure validates if a user is logged in or issues a 401
export const protectedProcedure = t.procedure.use(isAuthed);

// Defines a top-level router and 2 subrouters
// Defining subrouters helps with organization and frontend call structure
// The frontend will look like `trpc.clients.procedureName.useQuery()|useMutation()`
const trpcRouter = router({
clients: router(clients),

// Ensures `express` properly parses `body` params in POST requests to `/trpc`
app.use('/trpc', bodyParser.json());

// POST `/trpc` is now available for API calls
router: trpcRouter,

// Used in types.ts, a file shared by the frontend and backend
// This is CRITICAL for frontend type safety!!
export type TRPCRouter = typeof trpcRouter;

Adding procedures to tRPC router

From path/to/backend/procedures/clients.ts

// I prefer to store each procedure in individual file,
// but these could be defined inline
import { search } from '/path/to/backend/procedures/clients/search';
import { fetch } from '/path/to/backend/procedures/clients/fetch';

// Make these procedures available to the router
export default {


From path/to/shared/types.ts

For smaller projects, I prefer to put all my TS types in a single file that's shared between the frontend and backend.

Structure your filesystem and type definitions based on your team's needs and the size of your project.

import type { TRPCRouter } from 'path/to/backend/trpc-router';

export type TRPC = TRPCRouter;

Fetch procedure

From /path/to/backend/procedures/clients/fetch.ts

In the below example, we passed in clientUUID to trpc.clients.fetch.useQuery({ clientUUID }).

To understand how those arguments work we're going to take a look at Zod next, but first let's look at how simple created backend tRPC procedures is.

// From package.json
import z from 'zod';

// Ensures only logged-in users can request this data
import { protectedProcedure } from 'path/to/backend/trpc-router';

import { Client } from 'path/to/shared/types';

// Context function that inferfaces with our
// db to return a row from the `clients` table
import { fetchClient } from 'path/to/backend/context/clients/fetchClient';

// Our `zod` schema which we will review next
const fetchClientSchema = z.object({
clientUUID: z.string().uuid()

* Returns a `client` record from the db, if a match is found
* `ctx` contains our Context defined in `path/to/trpc-router`
* `input` contains input validated in the `.input()` function using `zod`
export const fetch = protectedProcedure
.query(async ({ ctx, input }) => {
const clientUUID = input.clientUUID;

const client = await fetchClient({ clientUUID });

I recommend always returning an object from procedures:

1. It makes refactoring or adding additional response data easier

2. More importantly, tRPC doesn't allow undefined responses
to be returned. This pattern prevents that by always
returning an object

Now, if we get `{client: null | undefined}`,
we won't trip a runtime error on the backend.
return { client } as { client: Client | null | undefined };

tRPC and React

This guide expects familiarity with react-query, specifically how useQuery and useMutation work.

If you are unfamiliar with react-query, refer to React Query's Quickstart, but once you understand the concepts, don't dive too deep, because @trpc/react-query does the heavy lifting.

From path/to/frontend/trpc.ts

// From package.json
import { createTRPCReact } from '@trpc/react-query';

// Shared frontend/backend file (the bridge providing tight TS definitions)
import type { TRPC } from 'path/to/shared/types';

// What we'll import from .tsx files
export const trpc = createTRPCReact<TRPC>();


From: path/to/frontend/index.tsx

// From package.json
import { QueryClientProvider } from '@tanstack/react-query';
import * as React from 'react';

import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';

import { Router } from 'path/to/frontend/Router';
import { reactQueryClient } from 'path/to/frontend/react-query-client';

createRoot(document.getElementById('root') as Element)
<QueryClientProvider client={reactQueryClient}>
<Router />


From path/to/frontend/Router.tsx

// From package.json
import { useState } from 'react';
import { httpBatchLink } from '@trpc/client';
import { QueryClient } from '@tanstack/react-query';

import { trpc } from 'path/to/frontend/trpc';
import { reactQueryClient } from 'path/to/frontend/react-query-client';

export function Router() {
// Allows for batching multiple tRPC requests in a single API request
const [ trpcClient ] = useState(() =>
links: [
url: `${host()}/trpc`,
fetch(url, options) {
return fetch(url, {
credentials: 'include',

return (
<trpc.Provider client={trpcClient} queryClient={reactQueryClient}>
<YourRouterHere />

function host() {
? 'http://localhost:3000'
: window.location.origin;

React Query Client

From path/to/frontend/react-query-client

import { QueryClient } from '@tanstack/react-query';

// Boilerplate for react-query so that the QueryClient is shared
// for tRPC and non-tRPC API requests
export const reactQueryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,

Client list

import { DataGrid } from '@mui/x-data-grid';

import { objectFromQS } 'path/to/formatters';
import { trpc } from 'path/to/frontend/trpc';

* List of clients rendered into Material UI's Datagrid
export default function Clients() {
// Converts `` to object {limit: 100, offset: 0}
const filters = objectFromQS();

Voila! 🪄

data: [...arrayOfClients],
isLoading: boolean,
refetch: () => void,
const clients =;

return (
rows={ ?? []}
field: 'first_name',
headerName: 'First name',
field: 'last_name',
headerName: 'Last name',

Client details

import { useParams } from 'react-router-dom';

import { trpc } from 'path/to/frontend/trpc';

* When a client row is clicked from the datagrid,
* render a detail card with information about that client
export default function Client() {
const { clientUUID } = useParams() as { clientUUID: string; };

const client = trpc.clients.fetch.useQuery({ clientUUID }); // 🪄

return (

Simple, magical, resilient ❤️