Right, so you’ve decided you’re tired of the soul-crushing tedium of manually keeping your frontend and backend types in sync. You’ve written a User type on the server, only to immediately copy-paste it into a @/types folder on the client, and you already know that the moment you add a lastLogin field, you’ll forget to update the client and everything will explode in production. It’s a ritual as old as time, and frankly, it’s beneath us.

Enter tRPC. The core idea is so stupidly simple you’ll wonder why it took this long: What if your backend functions were your API, and your frontend could just import and call them directly? No REST endpoints, no GraphQL schema to maintain, no code generation step. Just pure, unadulterated, type-safe functions. It feels like cheating, and I’m here to tell you that in this case, cheating is not only allowed—it’s encouraged.

The Magical Contract: The router

The heart of every tRPC application is the router. It’s not a mystical incantation; it’s just a strongly typed collection of your procedures (queries for fetching data, mutations for changing it). Think of it as a contract you define once on the server, which tRPC then rigorously enforces on the client.

Let’s build one. First, define your router’s context—this is where you inject dependencies like your database connection or authentication session, making them available to every procedure.

// server/context.ts
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getAuth } from '@clerk/nextjs/server';

export const createContext = async (opts: CreateNextContextOptions) => {
  const { req } = opts;
  const auth = getAuth(req);

  return {
    userId: auth.userId,
    // You'd add your db connection here, e.g., `db: prisma`
  };
};

export type Context = Awaited<ReturnType<typeof createContext>>;

Now, let’s create the main app router. We use publicProcedure for endpoints anyone can call, and later you can create protectedProcedure or adminProcedure that check the ctx.userId we just defined.

// server/routers/_app.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';

export const appRouter = router({
  // A query to get a user by ID
  getUser: publicProcedure
    .input(z.object({ id: z.string() })) // Validate input with Zod
    .query(async ({ input, ctx }) => {
      // In the real world, you'd use ctx.db here
      const user = await mockDbUsers.find(u => u.id === input.id);
      if (!user) {
        throw new Error('User not found'); // tRPC handles errors gracefully on the client
      }
      return user; // The type of this returned object is what the client will know!
    }),

  // A mutation to update a user's name
  updateUser: publicProcedure
    .input(z.object({ id: z.string(), name: z.string().min(1) }))
    .mutation(async ({ input }) => {
      // ... update logic here
      return { success: true, user: { id: input.id, name: input.name } };
    }),
});

// This is the type definition for our entire API. This is the golden ticket.
export type AppRouter = typeof appRouter;

See that export type AppRouter? That’s the whole game. We’re exporting the type of our router. The client will import this type and use it to know exactly what functions are available, what they take as input, and what they return.

The Client Side: Where the Magic Happens

Setting up the client in a Next.js app is straightforward. You wrap your application in a provider that knows how to talk to your API routes.

// pages/_app.tsx
import { withTRPC } from '@trpc/next';
import type { AppRouter } from '../server/routers/_app';

function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

export default withTRPC<AppRouter>({
  config({ ctx }) {
    const url = process.env.VERCEL_URL
      ? `https://${process.env.VERCEL_URL}/api/trpc`
      : 'http://localhost:3000/api/trpc';

    return { url };
  },
  ssr: true, // Enable server-side rendering for tRPC queries
})(MyApp);

Now, from any React component, you can import the trpc client and call your backend code as if it were a local function. The autocomplete is glorious.

// components/UserProfile.tsx
import { trpc } from '../utils/trpc';

export function UserProfile({ userId }) {
  // `data` is fully typed based on the return type of `getUser`!
  const { data: user, isLoading } = trpc.getUser.useQuery({ id: userId });

  const updateUser = trpc.updateUser.useMutation();

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <h1>Hello, {user?.name}</h1>
      <button
        onClick={() =>
          updateUser.mutate({ id: userId, name: 'New Name' })
        }>
        Update Name
      </button>
    </div>
  );
}

The best part? If you try to call getUser with the wrong input, say getUser.useQuery({ ID: userId }) (note the capitalization), TypeScript will scream at you immediately. You’ve caught the bug before you even saved the file, let alone ran the code.

The Rough Edges and Pitfalls

It’s not all rainbows and type-safe unicorns. You need to be aware of the trade-offs.

  1. It’s a TypeScript Monolith: This is the biggest one. tRPC is useless if your frontend is in Svelte, iOS, or Android. You’re building a TypeScript-centric system. If you need a public API for third parties, you’ll have to build a separate REST or GraphQL API. tRPC is for your clients.
  2. The babel Issue: Next.js uses Rust-based SWC by default, which doesn’t run TypeScript decorators. tRPC doesn’t need them, but if your project does, you have to switch to Babel, which slows down compilation. It’s a pain point the ecosystem is actively solving.
  3. Not Everything is a Function: While most API interactions map beautifully to functions, some things like file uploads are inherently… different. You’ll typically handle these by creating a separate vanilla Next.js API route and just not using tRPC for that one specific thing. It’s okay to be pragmatic.

The mental shift is the most important part. You’re no longer designing an API; you’re writing backend functions and getting a free, perfectly typed transport layer thrown in. It reduces the entire network boundary to a minor implementation detail. And once you get used to that, there’s absolutely no going back.