tRPC Made Simple: End-to-End Type Safety for React and Express Applications

Skip the confusion and get tRPC working in your React and Express app fast.

Published on July 8, 2025

Table of Contents

Why This?

tRPC has been one of my favorite additions to my preferred stack. Sometimes the initial setup can feel a bit confusing because the API is so versitile. I created this to help you get integrated quickly and back to building whatever you’re working on.

Prerequisites

Note: Vite’s latest needs a Node version greater than 20.19.0 or greater than/equal to 22.12.0 at the time of writing this.

To get started, let’s get our Vite-based React front-end going. If you’re thinking, “What about create-react-app?”, you can learn more here.

What We’re Using

Setting Up the Frontend

In the root of the project:

npm create vite@latest .

When prompted to select a framework, choose React and then either TypeScript or (what I choose) TypeScript + SWC.

Now you just need to install deps and bring the dev server up:

npm install && npm run dev

You should now be able to see your initial app up at http://localhost:5173

Setting Up the Back-end

Let’s create a separate directory for our backend and set up its dependencies:

mkdir server
cd server
npm init -y
npm install express cors dotenv
npm install --save-dev typescript @types/express @types/cors @types/node tsx nodemon

This keeps our frontend and backend dependencies separate, which is important for:

Even though we’ll deploy this as a single full-stack application, keeping dependencies separate makes the build and development process much cleaner.

Now let’s set up TypeScript configuration. Create server/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Now let’s create our basic Express server:

# Create the source directory and main server file
mkdir server/src
touch server/src/index.ts
import express, { Request, Response } from "express";
import cors from "cors";

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(cors());
app.use(express.json());

// Basic health check route
app.get("/api/health", (req: Request, res: Response) => {
  res.json({ status: "ok", message: "Server is running!" });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Let’s also set up the package.json scripts. Update server/package.json:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "nodemon --exec tsx src/index.ts"
  }
}

Create server/nodemon.json for better development experience:

{
  "watch": ["src"],
  "ext": "ts",
  "ignore": ["src/**/*.spec.ts"],
  "exec": "tsx ./src/index.ts"
}

Now let’s update the root package.json to add scripts that run both servers. Add these scripts to your root package.json. With concurrently, we’re able to name our terminal output and color code them for easy debugging as we bring them up together.

First, let’s install concurrently:

cd ..
npm install --save-dev concurrently

Then add our scripts:

{
  "scripts": {
    "dev": "concurrently -n \"client,server\" -c \"blue,green\" \"npm run dev:frontend\" \"npm run dev:backend\"",
    "dev:frontend": "vite",
    "dev:backend": "cd server && npm run dev",
    "build": "npm run build:frontend && npm run build:backend",
    "build:frontend": "vite build",
    "build:backend": "cd server && npm run build"
  }
}

Now you can start both servers with one command:

npm run dev

This will start:

You should see your client output in blue and your server output in green.

You can test the backend health endpoint at http://localhost:3000/api/health.

We now have:

Now that we have our stack up and running, it’s time to connect them both with tRPC so we get type-safety within our queries/mutations.

This will allow us to easily know we are passing the right things to the backend from the frontend and getting back from the backend what we expect on the frontend. Make a mistake? Your IDE will warn you.

tRPC Server Setup

First, let’s install what we need for both the server and the client.

In the server directory:

npm i @trpc/server zod

We can then make a file for our tRPC router:

touch server/src/trpc.ts
import { initTRPC } from "@trpc/server";

export const createContext = async () => {
  return {};
};

type Context = Awaited<ReturnType<typeof createContext>>;

/**
 * Initialization of tRPC backend
 * Should be done only once per backend!
 */
const t = initTRPC.context<Context>().create();

/**
 * Export reusable router and procedure helpers
 * that can be used throughout the router
 */
export const router = t.router;
export const publicProcedure = t.procedure;

Then in our server/src/index.ts file where our Express instance lives:

import z from "zod";
import express, { Request, Response } from "express";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import cors from "cors";
import { router, createContext, publicProcedure } from "./trpc";

const app = express();
const PORT = process.env.PORT || 3000;

export const appRouter = router({
  sayHello: publicProcedure
    .input(z.object({ name: z.string() }))
    .query(async ({ input }) => {
      return `hello ${input.name}`;
    }),
});

const corsOptions = {
  origin: "http://localhost:5173",
  credentials: true,
};

app.use(cors(corsOptions));
app.use(express.json());

app.get("/api/health", (req: Request, res: Response) => {
  res.json({ status: "ok", message: "Server is running!" });
});

app.use(
  "/trpc",
  createExpressMiddleware({
    router: appRouter,
    createContext,
    onError: ({ error }) => {
      console.error(error);
    },
  }),
);

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

export default app;

export type AppRouter = typeof appRouter;

This is essentially all you need for the basic tRPC server setup. Now let’s reach for our query from the front-end.

tRPC Client Setup

You can decide on this via the tRPC docs, but I use their approach with TanStack Query.

First, let’s install the dependencies we need:

npm i @trpc/client @trpc/tanstack-react-query @tanstack/react-query

Then make our client-side tRPC file:

touch src/trpc.ts

And in there:

import { QueryClient } from "@tanstack/react-query";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import type { AppRouter } from "../server/src/index";
import { createTRPCClient, httpBatchLink } from "@trpc/client";

export const queryClient = new QueryClient();

// Docs say localhost:3000 but I like to use server proxy with vite to bring things together seamlessly.
const trpcClient = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: "/trpc" })],
});

export const api = createTRPCOptionsProxy<AppRouter>({
  client: trpcClient,
  queryClient,
});

Warning: In this next code snippet, we’re doing it this way because we are building an SPA so we need the singleton approach. If you’re using an SSR framework, you’ll need to follow this path.

Now, we need to wrap our app with the query client provider. In main.tsx:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { queryClient } from "./trpc";
import { QueryClientProvider } from "@tanstack/react-query";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
);

Next we give Vite the server proxy to send calls for /trpc to localhost:3000. So in vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/trpc": {
        // We defined this path in our client-side src/trpc.ts file
        target: "http://localhost:3000",
        changeOrigin: true,
      },
    },
  },
});

And now in App.tsx:

import './App.css';
import { api } from './trpc';
import { useQuery } from '@tanstack/react-query';

function App() {
  const { data } = useQuery(api.sayHello.queryOptions({ name: 'John Wick' }));

  return (
    <>
      <p>{data}</p>
    </>
  );
}

export default App;

You should now see “hello John Wick” on your screen. Go ahead and change it and see it update. You now have end-to-end type-safety.