
When I first started building full-stack applications, the API layer always felt like a necessary evil. There was the constant dance of defining server-side endpoints, meticulously crafting client-side interfaces, and then praying that the two would remain in sync. A minor change on the backend could — and often did — lead to baffling runtime errors on the frontend, sometimes only discovered by users in production. The mental overhead was immense, and the developer experience? Let’s just say it left a lot to be desired.
We’ve had solutions like REST, which brought simplicity, and GraphQL, which offered powerful data fetching, but both still suffered from the same fundamental challenge: maintaining a perfectly synchronized contract between your client and server. This is where tRPC enters the scene, not as another API standard, but as a revolutionary approach to building truly type-safe APIs, specifically within the TypeScript ecosystem.
The Persistent Problem: Desynchronized APIs and Runtime Errors
Picture this: you’re building a feature that fetches user data. You define a REST endpoint like /api/users/:id that returns { id: string, name: string, email: string }. On your client, you write code that expects these fields. Months later, a backend developer changes email to userEmail to resolve a naming conflict. Unless they explicitly communicate this, and you manually update your client-side types and code, your application will break at runtime. The same applies to input validation – forget to validate an input on the server, and your database might get junk data. This is a common narrative, and it’s frustrating.
GraphQL, while a significant improvement in many areas, also introduces its own complexities. You get strong typing, but often at the cost of schema definition language (SDL) files, code generation steps, and a steeper learning curve for teams unfamiliar with its query language. While powerful, it still feels like an additional layer of abstraction that needs its own tooling and maintenance.
In my experience, the biggest friction point wasn't just writing the API, but constantly validating its integrity against the client. This often meant writing extensive integration tests, which are crucial, but often catch errors later in the development cycle than we'd prefer. We needed something that provided compile-time guarantees, without the boilerplate.
The Elegant Solution: tRPC to the Rescue
tRPC stands for "Type-safe Remote Procedure Call." Its core philosophy is brilliant in its simplicity: leverage TypeScript's powerful inference capabilities to derive your client-side types directly from your server-side API definitions. This means your API is always 100% type-safe, end-to-end, with zero code generation or schema files.
How does it achieve this magic? By treating your API as a collection of functions. When you define a procedure on your server, tRPC exposes that exact function signature—including its inputs, outputs, and any associated types—to your client. If you change a type on the server, your client-side code will immediately throw a TypeScript error, preventing runtime surprises.
The benefits are profound:
- Unmatched Developer Experience (DX): Enjoy auto-completion, instant feedback, and confident refactoring across your entire stack.
- End-to-End Type Safety: Catch API contract errors at compile time, not runtime, drastically reducing bugs.
- Zero Code Generation: No extra build steps, no schema files to maintain, just pure TypeScript.
- Performance & Simplicity: tRPC is incredibly lightweight, tree-shakable, and integrates seamlessly with popular data fetching libraries like React Query (TanStack Query).
- Small Bundle Size: Because it only ships the parts of the code you use, your client bundles remain lean.
When I first encountered tRPC and saw the auto-completion for an API response without any manual type generation, I was genuinely astonished. It felt like a fundamental shift in how we could approach full-stack development, moving from error-prone manual syncing to an integrated, type-guaranteed workflow.
Step-by-Step Guide: Building a Type-Safe Todo App with tRPC, Next.js, and Prisma
Let's dive into building a simple, but fully functional, type-safe Todo application. We'll use Next.js for our full-stack framework, Prisma as our ORM, and of course, tRPC for our API layer.
1. Project Setup: Initialize Next.js & Install Dependencies
First, create a new Next.js project with TypeScript and then install the necessary packages for tRPC and Prisma.
npx create-next-app@latest my-trpc-todo --typescript --eslint --app
cd my-trpc-todo
# Install tRPC and its adapters/plugins
npm install @trpc/server @trpc/react-query @trpc/client @tanstack/react-query zod
# Install Prisma
npm install prisma @prisma/client
npm install -D ts-node typescript
2. Database Setup with Prisma
Initialize Prisma and define your schema. For this example, we'll create a simple Todo model.
npx prisma init
Update prisma/schema.prisma:
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
datasource db {
  provider = "sqlite" // Using SQLite for simplicity
  url      = env("DATABASE_URL")
}
model Todo {
  id        String    @id @default(uuid())
  title     String
  completed Boolean   @default(false)
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}
Create a .env file at the root of your project:
# .env
DATABASE_URL="file:./dev.db"
Now, run the migration to create the database:
npx prisma migrate dev --name init_todos
3. Backend: Define Your tRPC Router and Procedures
This is where the magic happens. We'll define our tRPC context, initialize tRPC, and then create procedures (queries and mutations) for our Todo app.
Create a directory src/server/ and inside it, src/server/trpc.ts:
// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
import { PrismaClient } from '@prisma/client'; // Import PrismaClient
// Initialize Prisma
const prisma = new PrismaClient();
// You can use this for authentication, logging, etc.
export const createContext = async (opts: CreateNextContextOptions) => {
  // In a real app, you'd parse cookies/headers for auth
  return {
    req: opts.req,
    res: opts.res,
    prisma, // Attach Prisma client to context
  };
};
const t = initTRPC.context<typeof createContext>().transformer(superjson).create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});
// Base router and procedure helpers
export const router = t.router;
export const publicProcedure = t.procedure;
// For protected procedures (e.g., requires auth)
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  // Example: Check for a user session or token
  // if (!ctx.user) {
  //   throw new TRPCError({ code: 'UNAUTHORIZED' });
  // }
  return next({
    ctx: {
      // Infers the `user` as non-nullable
      // user: ctx.user,
    },
  });
});
Next, define your individual API routers in src/server/routers/todo.ts:
// src/server/routers/todo.ts
import { publicProcedure, router } from '../trpc';
import { z } from 'zod'; // For input validation
export const todoRouter = router({
  getTodos: publicProcedure
    .query(async ({ ctx }) => {
      // In a real app, you might filter by user ID from ctx
      return ctx.prisma.todo.findMany({
        orderBy: { createdAt: 'desc' },
      });
    }),
  addTodo: publicProcedure
    .input(z.object({
      title: z.string().min(1, 'Todo title cannot be empty'),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.todo.create({
        data: { title: input.title },
      });
    }),
  toggleTodo: publicProcedure
    .input(z.object({
      id: z.string().uuid(),
      completed: z.boolean(),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.todo.update({
        where: { id: input.id },
        data: { completed: input.completed },
      });
    }),
  deleteTodo: publicProcedure
    .input(z.object({
      id: z.string().uuid(),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.todo.delete({
        where: { id: input.id },
      });
    }),
});
Combine all your routers into a single root router in src/server/routers/_app.ts:
// src/server/routers/_app.ts
import { router } from '../trpc';
import { todoRouter } from './todo';
export const appRouter = router({
  todo: todoRouter, // Attach your todoRouter under the 'todo' namespace
});
// Export only the type definition of the API
// This is important for the client to infer types
export type AppRouter = typeof appRouter;
4. Next.js API Route
Create a catch-all API route for tRPC in src/app/api/trpc/[trpc]/route.ts (for Next.js App Router):
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app'; // Adjust path if needed
import { createContext } from '@/server/trpc'; // Adjust path if needed
const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: createContext,
  });
export { handler as GET, handler as POST };
5. Frontend: Create the tRPC Client and Context
Now, let's set up the client-side to consume our type-safe API. We'll use TanStack Query for data fetching and caching.
Create src/trpc/client.ts:
// src/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import { type AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
Create a provider component in src/trpc/Provider.tsx to wrap your application:
// src/trpc/Provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import React, { useState } from 'react';
import { trpc } from './client';
import superjson from 'superjson';
function getBaseUrl() {
  if (typeof window !== 'undefined') return ''; // Browser should use relative path
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // Vercel deployment
  return `http://localhost:${process.env.PORT ?? 3000}`; // Default to localhost
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          headers() {
            return {
              // You can add auth headers here
            };
          },
        }),
      ],
      transformer: superjson,
    })
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
Wrap your root layout with the provider in src/app/layout.tsx:
// src/app/layout.tsx
import './globals.css';
import { TRPCProvider } from '@/trpc/Provider';
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}
6. Frontend: Using Your Type-Safe API in Components
Now for the exciting part: consuming your tRPC procedures in a React component! Create src/app/page.tsx:
// src/app/page.tsx
'use client';
import { useState } from 'react';
import { trpc } from '@/trpc/client';
export default function Home() {
  const [newTodoTitle, setNewTodoTitle] = useState('');
  // Query to fetch all todos
  const { data: todos, isLoading, error } = trpc.todo.getTodos.useQuery();
  // Mutation to add a new todo
  const addTodoMutation = trpc.todo.addTodo.useMutation({
    onSuccess: () => {
      setNewTodoTitle('');
      trpc.todo.getTodos.invalidate(); // Invalidate cache to refetch todos
    },
  });
  // Mutation to toggle todo completion
  const toggleTodoMutation = trpc.todo.toggleTodo.useMutation({
    onSuccess: () => {
      trpc.todo.getTodos.invalidate();
    },
  });
  // Mutation to delete a todo
  const deleteTodoMutation = trpc.todo.deleteTodo.useMutation({
    onSuccess: () => {
      trpc.todo.getTodos.invalidate();
    },
  });
  const handleAddTodo = (e: React.FormEvent) => {
    e.preventDefault();
    if (newTodoTitle.trim()) {
      addTodoMutation.mutate({ title: newTodoTitle });
    }
  };
  if (isLoading) return <div>Loading todos...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return (
    <main className="container mx-auto p-4 max-w-lg">
      <h1 className="text-3xl font-bold mb-6 text-center">tRPC Todo App</h1>
      <form onSubmit={handleAddTodo} className="mb-6 flex gap-2">
        <input
          type="text"
          value={newTodoTitle}
          onChange={(e) => setNewTodoTitle(e.target.value)}
          placeholder="What needs to be done?"
          className="flex-grow p-2 border rounded shadow-sm focus:ring-blue-500 focus:border-blue-500"
        />
        <button
          type="submit"
          disabled={addTodoMutation.isPending}
          className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
        >
          {addTodoMutation.isPending ? 'Adding...' : 'Add Todo'}
        </button>
      </form>
      <ul className="space-y-3">
        {todos?.map((todo) => (
          <li key={todo.id} className="flex items-center justify-between p-3 border rounded shadow-sm">
            <div className="flex items-center gap-3">
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodoMutation.mutate({ id: todo.id, completed: !todo.completed })}
                className="form-checkbox h-5 w-5 text-blue-600 rounded"
              />
              <span className={`text-lg ${todo.completed ? 'line-through text-gray-500' : ''}`} >
                {todo.title}
              </span>
            </div>
            <button
              onClick={() => deleteTodoMutation.mutate({ id: todo.id })}
              disabled={deleteTodoMutation.isPending}
              className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </main>
  );
}
A quick note: For styling, I'm assuming you have Tailwind CSS set up or are using some basic CSS in src/app/globals.css. The classes like container, mx-auto, p-4, etc., are Tailwind classes. If you don't have Tailwind, you'll need to add basic CSS for visual appeal.
Now, when you type trpc.todo. in your client code, your IDE will provide full auto-completion for getTodos, addTodo, toggleTodo, and deleteTodo, along with their expected inputs and return types. If you change the title field to description in your Prisma schema and tRPC router, your client-side code consuming todo.title will instantly show a TypeScript error. This compile-time feedback is invaluable.
Outcome and Key Takeaways
By adopting tRPC, we've achieved a seamless, truly type-safe full-stack development experience. Here are the major benefits:
- End-to-End Type Safety: We eliminated an entire class of bugs related to client-server API mismatches. Changes on the server automatically reflect as TypeScript errors on the client, preventing runtime surprises.
- Superior Developer Experience (DX): The auto-completion and instantaneous feedback loop provided by tRPC are unparalleled. Developers can work faster and with more confidence, knowing their API contracts are always consistent.
- Simplified Architecture: No more juggling REST conventions, GraphQL SDLs, or complex code generation steps. tRPC leverages existing TypeScript features to provide a lean, intuitive API layer.
- Blazing-Fast Development: With fewer bugs, less boilerplate, and excellent tooling support, feature development cycles are significantly shortened. Your team can focus on business logic rather than API plumbing.
- Lightweight and Performant: tRPC itself is minimal, and its integration with TanStack Query ensures efficient data fetching, caching, and invalidation without adding unnecessary bloat to your bundle.
In our team, the shift to tRPC for new projects dramatically reduced the amount of time spent debugging API-related issues. It felt like we finally had a unified language for our client and server, rather than two separate worlds trying to communicate. The confidence it brings to refactoring complex API logic is a game-changer.
Conclusion
tRPC represents a significant leap forward in full-stack TypeScript development. It doesn't aim to replace REST or GraphQL entirely, but rather offers a highly specialized, incredibly effective solution for projects that value end-to-end type safety and a superior developer experience above all else. If you're building a full-stack application with TypeScript, especially with frameworks like Next.js or Nuxt, tRPC is an architectural pattern you simply cannot afford to ignore.
Give tRPC a try in your next project. You'll likely find yourself wondering how you ever built full-stack applications without its compile-time guarantees and developer-friendly approach. The future of type-safe APIs is here, and it's delightfully simple.