Background & The Problem to Solve
The backend team just renamed user_id to id in the response payload. Nobody on the frontend was informed. As soon as it deployed to staging, the app crashed instantly: TypeError: Cannot read properties of undefined.
This scenario happens all the time in decoupled client-server projects. With traditional REST, both sides communicate via raw JSON without any statically enforced type contract. To address this, teams typically choose between two solutions:
- Writing Swagger/OpenAPI specs and generating code: Every minor endpoint tweak requires running a build script. Spec files bloat quickly and become a chore to maintain.
- GraphQL: Excellent type safety, but overly heavy. You have to write SDL schemas, build resolvers, and endure steep configuration overhead just for an internal application.
tRPC fundamentally solves this problem in a much cleaner way. No intermediate schemas. No code generation build steps.
It leverages TypeScript’s native type inference directly. Every router and procedure written in Node.js automatically maps its types to the React client. The moment backend renames a field, your client-side IDE underlines it in red immediately.
In a recent 5-developer dashboard project, our team decided to switch from REST to tRPC. The results were astounding: weekly API alignment meetings dropped from four hours to virtually zero. Trivial bugs caused by payload field typos vanished entirely.
Folder Structure & Setup
A Monorepo setup (using npm workspaces, pnpm, or Turborepo) brings out the best in tRPC. The frontend can import types from the backend without bundling a single byte of server runtime code. Recommended structure:
my-project/
├── client/ # React app (Vite / Next.js)
├── server/ # Node.js app (Express / Fastify)
└── package.json
Install the core libraries for the server along with zod for input schema validation:
cd server
npm install @trpc/server zod cors express
npm install -D @types/cors @types/express typescript
Switch to the client/ directory to install the tRPC client and TanStack Query (React Query) for cache management:
cd ../client
npm install @trpc/client @trpc/react-query @tanstack/react-query
Step-by-Step Configuration
1. Set Up tRPC Context and Router on the Backend
Initialize the tRPC instance in server/src/trpc.ts:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
Build the business logic router in server/src/routers/user.ts. Notice how Zod validates runtime data while simultaneously inferring input types at compile time:
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
const users = [
{ id: '1', name: 'Nguyen Van A', role: 'admin' as const },
{ id: '2', name: 'Tran Thi B', role: 'member' as const },
];
export const appRouter = router({
// Query: Get list
getUserList: publicProcedure.query(() => {
return users;
}),
// Query: Get details with validated parameters
getUserById: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const user = users.find((u) => u.id === input.id);
return user || null;
}),
// Mutation: Create a new user
createUser: publicProcedure
.input(z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
role: z.enum(['admin', 'member']),
}))
.mutation(({ input }) => {
const newUser = { id: String(Date.now()), ...input };
users.push(newUser);
return newUser;
}),
});
// Key takeaway: Only export the router's TYPE to the frontend
export type AppRouter = typeof appRouter;
Create the HTTP server with the Express adapter in server/src/index.ts:
import express from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './routers/user';
const app = express();
app.use(cors());
// Register the entire router under the /trpc prefix
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
})
);
app.listen(4000, () => {
console.log('tRPC server is listening at http://localhost:4000/trpc');
});
2. Configure tRPC Hooks on the React Frontend
Create the type bridge file in client/src/utils/trpc.ts:
import { createTRPCReact } from '@trpc/react-query';
// Use `import type`: The compiler drops this import during the JS build
import type { AppRouter } from '../../../server/src/routers/user';
export const trpc = createTRPCReact<AppRouter>();
Wrap the root component with providers in client/src/App.tsx:
import React, { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './utils/trpc';
import { UserList } from './components/UserList';
export function App() {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: 1 },
},
}));
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:4000/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
</trpc.Provider>
);
}
3. Call APIs with Full Autocomplete
Write the component to display and interact with data in client/src/components/UserList.tsx:
import React from 'react';
import { trpc } from '../utils/trpc';
export function UserList() {
// `data` automatically infers type { id: string, name: string, role: 'admin' | 'member' }[]
const { data, isLoading, error } = trpc.getUserList.useQuery();
const utils = trpc.useUtils();
const createMutation = trpc.createUser.useMutation({
onSuccess: () => {
// Invalidate cache to automatically refetch the latest list
utils.getUserList.invalidate();
},
});
if (isLoading) return <p>Loading list...</p>;
if (error) return <p style={{ color: 'red' }}>Error: {error.message}</p>;
return (
<div>
<h3>Member List ({data?.length || 0})</h3>
<ul>
{data?.map((user) => (
<li key={user.id}>
{user.name} — <strong>{user.role}</strong>
</li>
))}
</ul>
<button
disabled={createMutation.isPending}
onClick={() => createMutation.mutate({ name: 'Le Van C', role: 'member' })}
>
{createMutation.isPending ? 'Adding...' : 'Add New User'}
</button>
</div>
);
}
Testing & Production Readiness
Getting code running locally is only half the battle. Here is how to verify type safety and add protective layers before deploying to production.
1. Testing by “Breaking” Type Safety
Try opening server/src/routers/user.ts and renaming the role field to position. Then switch over to the UserList.tsx tab on the client:
The line user.role is instantly flagged with a red squiggly line and an error: Property 'role' does not exist on type.... You do not need to run any build command. The error is caught right in your editor within 50ms.
2. Adding Middleware to Measure Execution Time
Log response times to catch bottleneck queries early. Add middleware to server/src/trpc.ts:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const loggingMiddleware = t.middleware(async ({ path, type, next }) => {
const start = performance.now();
const result = await next();
const durationMs = Math.round(performance.now() - start);
if (result.ok) {
console.log(`[tRPC] ${type.toUpperCase()} '${path}' completed in ${durationMs}ms`);
} else {
console.error(`[tRPC ERROR] ${type.toUpperCase()} '${path}' failed (${durationMs}ms):`, result.error.message);
}
return result;
});
// Standard procedure with logging included
export const loggedProcedure = t.procedure.use(loggingMiddleware);
3. Standardizing Error Codes with TRPCError
Avoid throwing generic throw new Error() indiscriminately, as it can easily leak sensitive stack traces to the client. Instead, use TRPCError to map appropriate HTTP status codes:
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { loggedProcedure } from '../trpc';
export const getProfile = loggedProcedure
.input(z.object({ token: z.string().min(1) }))
.query(async ({ input }) => {
if (input.token !== 'valid-secret-token') {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Session has expired',
});
}
return { id: '1', email: '[email protected]' };
});
The client then receives the precise error code via error.data?.code === 'UNAUTHORIZED'. Redirecting users to the login page becomes safe, consistent, and leaves no edge cases overlooked.

