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?
- Prerequisites
- What We’re Using
- Setting Up the Frontend
- Setting Up the Back-end
- tRPC Server Setup
- tRPC Client Setup
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
- Node.JS (stick with LTS for this, through whatever Node Version Manager you use)
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:
- Security: Backend-only packages don’t get bundled with frontend
- Build process: Frontend and backend have different build steps
- Development: Easier to run frontend dev server and backend API separately
- Dependency management: Avoid version conflicts between frontend and backend packages
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:
- Frontend at http://localhost:5173
- Backend at http://localhost:3000
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:
- Type safety in the front- and back-end which allows…
- Better IDE support with autocomplete and error detection
- Easier refactoring with confidence
- Shared types that can be used across frontend and backend
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.