Build Type-Safe APIs with tRPC v12 in 20 Minutes

Implement end-to-end type safety between your frontend and backend using tRPC v12 with GitHub Copilot assistance.

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.


// 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:

  1. Change the backend return type:
// server/routers/user.ts
return {
  id: input.id,
  fullName: 'Alice', // Changed from 'name' to 'fullName'
  email: 'alice@example.com',
};
  1. 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.

  1. Rename data.name to data.fullName — error disappears.

Test Copilot assistance:

  1. Type trpc.user. — Copilot suggests getUser and createUser
  2. Type const { data } = — Copilot suggests the full query hook
  3. Type data. — Copilot suggests id, 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.json paths 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.tsx has both trpc.Provider and QueryClientProvider

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