End-to-End Type Safety Using tRPC and TanStack Query
Achieve complete end-to-end type safety between your backend and React frontend using tRPC and TanStack Query without writing manual API contracts or schemas.
In traditional full-stack web applications, maintaining consistency between API endpoints and frontend data consumption requires constant synchronization. Writing manual TypeScript interfaces, OpenAPI specifications, or GraphQL schemas often leads to drift, runtime validation bugs, and mismatched payload structures when backend controllers change.
tRPC eliminates this friction entirely. By leveraging TypeScript's advanced type inference systems, tRPC allows you to share types directly between your server and client without code generation or manual schema duplication.
This comprehensive guide explores how to build end-to-end type-safe APIs using tRPC and TanStack Query, covering backend router definitions, client setup, query execution, and mutation handling in React.
The Power of Full-Stack Type Inference
To appreciate the synergy between tRPC and TanStack Query, it helps to examine the architectural benefits they bring to modern React applications:
• Zero Schema Duplication: Your backend router definition acts as the single source of truth for types. If you change a database field or rename a return property on the server, TypeScript instantly flags errors in every React component consuming that endpoint.
• Native TanStack Query Integration: `@trpc/react-query` bridges tRPC routers directly with TanStack Query hooks, giving you automatic caching, background refetching, pagination, and optimistic updates with full type safety out of the box.
• Lightning-Fast Developer Experience: Autocomplete works seamlessly across query inputs, parameters, and response structures without needing to switch context or inspect network tabs.
Defining App Routers and Procedures
To start using tRPC, install the core packages and initialize your tRPC server context and router instances.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
interface User {
id: string;
name: string;
email: string;
}
const users: User[] = [
{ id: '1', name: 'Alice Smith', email: 'alice@example.com' },
{ id: '2', name: 'Bob Johnson', email: 'bob@example.com' },
];
export const appRouter = router({
getUserById: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const user = users.find((u) => u.id === input.id);
return user || null;
}),
createUser: publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(({ input }) => {
const newUser: User = { id: String(users.length + 1), ...input };
users.push(newUser);
return newUser;
}),
});
export type AppRouter = typeof appRouter;
Fetching and Mutating Data with Full Type Safety
On the React frontend, you can use the generated `AppRouter` type to initialize your tRPC React hooks, enabling fully type-safe queries and mutations.
'use client';
import { trpc } from '@/utils/trpc';
import { useState } from 'react';
export default function UserProfile({ userId }: { userId: string }) {
const utils = trpc.useUtils();
const { data: user, isLoading, error } = trpc.getUserById.useQuery({ id: userId });
const [newName, setNewName] = useState('');
const createUserMutation = trpc.createUser.useMutation({
onSuccess: () => {
utils.getUserById.invalidate();
},
});
if (isLoading) return <p>Loading user profile...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div className="p-6 bg-white rounded-xl shadow max-w-lg mx-auto">
<h2 className="text-2xl font-bold mb-2">User Profile</h2>
{user ? (
<div className="space-y-1 mb-6">
<p className="text-gray-800 font-medium">Name: {user.name}</p>
<p className="text-gray-600">Email: {user.email}</p>
</div>
) : (
<p className="text-gray-500 mb-6">User not found.</p>
)}
<div className="border-t pt-4">
<h3 className="text-lg font-semibold mb-2">Add New User</h3>
<form
onSubmit={(e) => {
e.preventDefault();
createUserMutation.mutate({ name: newName, email: `${newName.toLowerCase()}@example.com` });
}}
className="flex gap-2"
>
<input
type="text"
placeholder="Full Name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="px-3 py-2 border rounded-lg flex-1"
/>
<button type="submit" className="px-4 py-2 bg-indigo-600 text-white rounded-lg">
Create
</button>
</form>
</div>
</div>
);
}
Summary
Combining tRPC with TanStack Query provides the ultimate end-to-end type safety experience for full-stack React and Next.js applications.
By eliminating manual API contracts, schema duplication, and runtime payload mismatches, developers can ship features faster while maintaining absolute confidence in their type definitions across the entire stack.