Problem: API Types Are Always Out of Sync
You're tired of manually writing TypeScript interfaces for your API responses, only to discover runtime errors when the backend changes. Your frontend and backend speak different languages, and GitHub Copilot can't help because it doesn't know what your API returns.
You'll learn:
- How tRPC v12 shares types between client and server automatically
- Setting up a monorepo with proper TypeScript paths
- Using Copilot effectively with tRPC's type inference
Time: 20 min | Level: Intermediate
Why This Happens
Traditional REST APIs require you to manually duplicate type definitions across frontend and backend. When the backend changes, the frontend breaks at runtime—not compile time. tRPC solves this by sharing the actual TypeScript types from your backend procedures directly with your frontend code.
Common symptoms:
- Runtime errors like "Cannot read property 'id' of undefined"
- Constantly updating frontend types after backend changes
- Copilot suggesting incorrect API response shapes
- No autocomplete for API endpoints or response data
Solution
Step 1: Install tRPC v12 Dependencies
# Backend dependencies
npm install @trpc/server@next zod
# Frontend dependencies
npm install @trpc/client@next @trpc/react-query@next @tanstack/react-query
# Development
npm install -D typescript @types/node
Expected: All packages install without peer dependency warnings. tRPC v12 uses @next tag until stable release.
If it fails:
- Peer dependency conflicts: Use
npm install --legacy-peer-deps - Wrong Node version: tRPC v12 requires Node 18+
Step 2: Create Your Backend Router
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
// Initialize tRPC with type safety
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
// Zod validates input at runtime AND provides TypeScript types
export const userRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// Your database logic here
return {
id: input.id,
name: 'Alice',
email: 'alice@example.com',
createdAt: new Date(),
};
}),
createUser: publicProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
// This runs on mutation requests
const user = { id: crypto.randomUUID(), ...input };
return user;
}),
});
// server/routers/_app.ts - combine all routers
import { router } from '../trpc';
import { userRouter } from './user';
export const appRouter = router({
user: userRouter,
});
// Export type definition for client
export type AppRouter = typeof appRouter;
Why this works: The AppRouter type contains the complete shape of your API. When imported on the frontend, TypeScript knows every endpoint, input, and output type without manual definitions.
Step 3: Set Up TypeScript Path Mapping
// tsconfig.json (backend)
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@server/*": ["./server/*"]
}
}
}
// tsconfig.json (frontend - if separate)
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"baseUrl": ".",
"paths": {
"@server/*": ["../server/*"] // Points to backend
}
}
}
Critical: The frontend's paths config must reference your backend directory so TypeScript can find the AppRouter type. This creates zero-runtime-cost type sharing.
Step 4: Initialize the Frontend Client
// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@server/routers/_app';
// Type-safe React hooks for your entire API
export const trpc = createTRPCReact<AppRouter>();
// client/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './trpc';
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
// Batches multiple requests into one HTTP call
}),
],
});
function App() {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
</trpc.Provider>
);
}
Why batching matters: If you make 5 API calls on page load, tRPC combines them into 1 HTTP request automatically. This reduces latency significantly.
Step 5: Use Type-Safe Hooks in Components
// components/UserProfile.tsx
import { trpc } from '../trpc';
export function UserProfile({ userId }: { userId: string }) {
// Full autocomplete: trpc.user.getUser
// TypeScript knows the input shape: { id: string }
// TypeScript knows the return type without manual interface
const { data, isLoading } = trpc.user.getUser.useQuery({
id: userId
});
if (isLoading) return <div>Loading...</div>;
// TypeScript knows 'data' has: id, name, email, createdAt
// GitHub Copilot now suggests correct property names
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
<small>Joined {data.createdAt.toLocaleDateString()}</small>
</div>
);
}
// Mutations work the same way
export function CreateUserForm() {
const mutation = trpc.user.createUser.useMutation();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// TypeScript enforces correct input shape
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
// If you typo 'emai', TypeScript shows error immediately
});
};
return <form onSubmit={handleSubmit}>...</form>;
}
What GitHub Copilot now understands: Because TypeScript sees the full API shape, Copilot autocompletes endpoint names, input properties, and response data fields accurately. It's like having your API documentation built into the IDE.
Step 6: Add Server Middleware (Optional but Recommended)
// server/server.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { appRouter } from './routers/_app';
const server = createHTTPServer({
router: appRouter,
createContext: () => ({}), // Add auth context here later
});
server.listen(3000);
console.log('tRPC server running on http://localhost:3000');
For production, use Next.js App Router or Express:
// With Next.js 15+ App Router
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@server/routers/_app';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
Verification
Test the type safety:
- Change the backend return type:
// server/routers/user.ts
return {
id: input.id,
fullName: 'Alice', // Changed from 'name' to 'fullName'
email: 'alice@example.com',
};
- Check your frontend component:
<h1>{data.name}</h1> // TypeScript error: Property 'name' does not exist
You should see: Red squiggly lines in your editor immediately. No need to run the app to discover the breakage.
- Rename
data.nametodata.fullName— error disappears.
Test Copilot assistance:
- Type
trpc.user.— Copilot suggestsgetUserandcreateUser - Type
const { data } =— Copilot suggests the full query hook - Type
data.— Copilot suggestsid,fullName,email,createdAt
What You Learned
- tRPC shares backend types with frontend through TypeScript, not codegen
- Zod validates input at runtime AND generates TypeScript types
- Request batching reduces HTTP overhead automatically
- GitHub Copilot becomes accurate because TypeScript knows your API
Limitations:
- Requires monorepo or TypeScript path mapping to shared types
- Backend and frontend must both use TypeScript
- Not ideal for public APIs consumed by third parties (use OpenAPI/REST)
When NOT to use tRPC:
- Public APIs for external developers (no type sharing)
- Polyglot systems (Go backend + React frontend)
- Teams that prefer code generation over type imports
Next steps:
- Add authentication with tRPC middleware
- Implement optimistic updates with React Query
- Deploy with Docker (backend + frontend in one container)
Advanced: Using Copilot for tRPC Development
Prompt patterns that work well:
// 1. Ask Copilot to create procedures
// Comment: "create a procedure to update user profile"
// Copilot suggests:
updateProfile: publicProcedure
.input(z.object({ id: z.string(), bio: z.string().optional() }))
.mutation(async ({ input }) => { /* implementation */ }),
// 2. Request input validation
// Comment: "add validation for phone number in E.164 format"
// Copilot suggests:
phone: z.string().regex(/^\+[1-9]\d{1,14}$/),
// 3. Generate test data factories
// Comment: "create a function that returns mock user data"
// Copilot generates type-safe factories automatically
Why Copilot is better with tRPC:
- It sees the Zod schemas that define valid inputs
- It knows the return types from previous procedures
- It understands the router structure from your existing code
Troubleshooting
"Cannot find module '@server/routers/_app'"
- Fix: Check
tsconfig.jsonpaths point to correct directory - Frontend needs:
"@server/*": ["../server/*"](adjust for your structure)
"Type instantiation is excessively deep"
- Cause: Circular type references or deeply nested routers
- Fix: Split large routers into smaller ones, avoid circular imports
"Property 'useQuery' does not exist"
- Cause: Forgot to wrap app in
trpc.Provider - Fix: Ensure
main.tsxhas bothtrpc.ProviderandQueryClientProvider
Copilot suggests wrong types
- Cause: TypeScript server hasn't updated
- Fix: Restart TypeScript server (VS Code: Cmd+Shift+P → "Restart TS Server")
Tested on tRPC v11.0.0-next.330, TypeScript 5.5, Next.js 15, React 19