End-to-End Typesafe APIs with tRPC

27. September, 2025 15 min read Develop

The same shape, written twice

Every full-stack TypeScript project I have worked on ends up with the same quiet tax. The server knows what a user looks like. The client also knows what a user looks like. Those two pieces of knowledge live in different files, and sooner or later they disagree with each other. The disagreement never announces itself at compile time. It shows up in production, as undefined is not a function.

tRPC is a fairly blunt answer to that. Instead of describing your API in a schema and generating types from it, you write server functions and let TypeScript’s inference carry their signatures across to the client. No SDL, no codegen step, no openapi.json sitting in the repo going stale. It pairs particularly well with a Next.js app that already talks to a typed database layer such as Prisma, because at that point the types run all the way from a column definition to a button handler.

I want to walk through tRPC v11 in a Next.js App Router project, because v11 changed the React integration enough that most of the tutorials you find are subtly wrong.

Why bother

Take the ordinary REST case. You add a GET /api/users/:id route. On the client you write an interface User so that fetch() returns something better than any. Now there are two definitions of a user in the codebase and nothing at all connecting them. Rename a field on the server and the client compiles happily, right up until it renders undefined.

GraphQL fixes the connection problem by making the schema the single source of truth. It charges you for it, though: a schema language, resolvers, a codegen step, and a build pipeline that has to run in the right order or your types are from last Tuesday. For a large team serving several clients that’s a fair trade. For one person shipping a Next.js app it is a lot of ceremony.

tRPC removes the middle layer entirely. There is no schema because the router is the schema, and the client reads its type. Change a procedure’s input and the call sites go red immediately, in the editor, before you have saved anything. I found that hard to give up again after a week of it.

The obvious catch is that both ends have to be TypeScript. tRPC gives you nothing if a Swift app or a partner’s Python service needs to call your API. For those, REST with an OpenAPI document or GraphQL are still the right answers. But if the API only ever serves your own Next.js frontend, the polyglot flexibility you are paying for is flexibility you will never use.

The pieces

There are five nouns and you need all of them:

  • Procedures are the endpoints. A query reads, a mutation writes, a subscription streams.
  • Routers group procedures under a namespace, and nest as deep as you like.
  • Context is per-request state (the session, a database handle) that every procedure receives.
  • Middleware wraps procedures, and crucially can narrow the context type for everything downstream.
  • Validators check input and, optionally, output.

That fourth point is the one people underestimate. I will come back to it.

Wiring it into Next.js

1. Install

npm install @trpc/server @trpc/client @trpc/tanstack-react-query \
  @tanstack/react-query zod

The React bindings live in @trpc/tanstack-react-query now. If you find a tutorial importing createTRPCReact from @trpc/react-query, that is the v10 integration. It still works in v11, but it is not the one being developed.

2. Initialise tRPC

One instance, created once, exporting the builders everything else uses:

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import type { db } from '@/lib/db';

export type Context = {
  user: { id: string; name: string; role: string } | null;
  db: typeof db;
};

const t = initTRPC.context<Context>().create();

export const router = t.router;
export const publicProcedure = t.procedure;
export { TRPCError };

3. Write a router

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

export const usersRouter = router({
  list: publicProcedure.query(async ({ ctx }) => {
    return ctx.db.user.findMany();
  }),

  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id },
      });

      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'User not found',
        });
      }

      return user;
    }),

  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(1),
        email: z.string().email(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      return ctx.db.user.create({ data: input });
    }),
});

The .input() call does double duty. At runtime it validates and rejects bad payloads; at compile time it types input inside the handler as { name: string; email: string }. You write the schema once and get both. Zod is the common choice but not a requirement, since v11 accepts anything implementing Standard Schema (Valibot, ArkType and friends all work).

4. Merge into an app router

// server/routers/index.ts
import { router } from '../trpc';
import { usersRouter } from './users';
import { postsRouter } from './posts';

export const appRouter = router({
  users: usersRouter,
  posts: postsRouter,
});

// Export the TYPE, never the implementation
export type AppRouter = typeof appRouter;

That last line matters more than it looks. export type is erased at build time, so importing AppRouter in a client component pulls in exactly zero bytes of server code. Export the value by accident and you have just shipped your database queries to the browser.

5. Serve it

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
import { createContext } from '@/server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  });

export { handler as GET, handler as POST };

One catch-all route, and every procedure in the tree is reachable.

6. Set up the client

This is the part that changed in v11. createTRPCContext hands you a provider and a hook, and the client itself comes from @trpc/client:

// lib/trpc.ts
'use client';

import { createTRPCContext } from '@trpc/tanstack-react-query';
import type { AppRouter } from '@/server/routers';

export const { TRPCProvider, useTRPC, useTRPCClient } =
  createTRPCContext<AppRouter>();
// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { TRPCProvider } from '@/lib/trpc';
import type { AppRouter } from '@/server/routers';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [httpBatchLink({ url: '/api/trpc' })],
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        {children}
      </TRPCProvider>
    </QueryClientProvider>
  );
}

Note trpcClient, not client. I lost a while to that one.

Calling it from a component

v11 dropped the old trpc.users.list.useQuery() style in favour of handing you plain TanStack Query option objects. It reads as slightly more verbose and is much less magical:

'use client';

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/lib/trpc';

export function UserList() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const { data: users, isPending } = useQuery(trpc.users.list.queryOptions());

  const createUser = useMutation(
    trpc.users.create.mutationOptions({
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: trpc.users.list.queryKey() });
      },
    })
  );

  if (isPending) return <div>Loading…</div>;

  return (
    <div>
      <ul>
        {users?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      <button
        onClick={() =>
          createUser.mutate({ name: 'New User', email: 'new@example.com' })
        }
      >
        Add User
      </button>
    </div>
  );
}

The win over the v10 hooks is that useQuery here is the real TanStack hook. Anything you know about select, enabled, placeholderData or suspense applies directly, and you are not waiting for tRPC to wrap each new Query option. queryKey() giving you the exact key tRPC used is the small detail that makes invalidation stop being guesswork.

Context, and the middleware trick

Context is built once per request:

// server/context.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const createContext = async (opts: { req: Request }) => {
  const session = await auth(opts.req);

  return {
    user: session?.user ?? null,
    db,
  };
};

Note that user is nullable, because most requests are anonymous. Middleware is where that gets fixed:

// server/trpc.ts
export const authedProcedure = publicProcedure.use(async (opts) => {
  if (!opts.ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }

  return opts.next({
    ctx: { user: opts.ctx.user },
  });
});

export const adminProcedure = authedProcedure.use(async (opts) => {
  if (opts.ctx.user.role !== 'admin') {
    throw new TRPCError({ code: 'FORBIDDEN' });
  }

  return opts.next();
});

Now build the router out of those instead of publicProcedure:

export const usersRouter = router({
  list: publicProcedure.query(async ({ ctx }) => ctx.db.user.findMany()),

  create: authedProcedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ ctx, input }) => ctx.db.user.create({ data: input })),

  delete: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) =>
      ctx.db.user.delete({ where: { id: input.id } })
    ),
});

Here is the bit I actually like. Inside authedProcedure, ctx.user is typed non-null, because opts.next({ ctx }) narrows the context for everything downstream. Inside adminProcedure you can reach ctx.user.role without a single optional chain. The authorisation check and the type narrowing are the same line of code, which means you cannot skip the check and still compile. That is a much stronger guarantee than a linting rule about calling requireAuth() first.

Middleware also does the boring cross-cutting work:

const loggedProcedure = publicProcedure.use(async (opts) => {
  const start = Date.now();
  const result = await opts.next();
  console.log(`${opts.path} completed in ${Date.now() - start}ms`);
  return result;
});

When things go wrong

TRPCError takes a code from a fixed list, each mapped to an HTTP status:

import { TRPCError } from '@trpc/server';

throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' }); // 404
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid input' }); // 400
throw new TRPCError({ code: 'FORBIDDEN', message: 'Access denied' }); // 403
throw new TRPCError({ code: 'CONFLICT', message: 'Slug already taken' }); // 409

An input that fails Zod validation becomes a BAD_REQUEST on its own; you do not need to catch and rethrow. On the client the error arrives through React Query’s normal channel:

const { data, error } = useQuery(trpc.users.byId.queryOptions({ id }));

if (error) {
  // error.data?.code is 'NOT_FOUND', 'UNAUTHORIZED', …
  return <div>Error: {error.message}</div>;
}

And for everything you would rather see in your logs than in a user’s browser, the handler takes an onError:

fetchRequestHandler({
  endpoint: '/api/trpc',
  req,
  router: appRouter,
  createContext,
  onError: ({ error, path }) => {
    console.error(`tRPC error on ${path}:`, error);
  },
});

One thing worth knowing: in production tRPC strips the stack trace out of INTERNAL_SERVER_ERROR responses and replaces the message. That is the right default, and it does mean onError is the only place you will ever see the real cause.

Prefetching from a Server Component

Client components fetching on mount produce the waterfall everyone complains about: HTML arrives, React hydrates, then the request goes out. You can hoist the fetch into the Server Component that renders them.

The server-side proxy needs to be told how to build a context, which router to call, and where to put the results:

// lib/trpc-server.ts
import 'server-only';
import { cache } from 'react';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import { appRouter } from '@/server/routers';
import { createContext } from '@/server/context';
import { makeQueryClient } from './query-client';

export const getQueryClient = cache(makeQueryClient);

export const trpc = createTRPCOptionsProxy({
  ctx: createContext,
  router: appRouter,
  queryClient: getQueryClient,
});

cache() is not optional decoration. Without it you get a fresh QueryClient per call and nothing you prefetch ends up in the one that gets dehydrated.

// app/users/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { trpc, getQueryClient } from '@/lib/trpc-server';
import { UserList } from './user-list';

export default async function UsersPage() {
  const queryClient = getQueryClient();

  void queryClient.prefetchQuery(trpc.users.list.queryOptions());

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserList />
    </HydrationBoundary>
  );
}

Because the proxy calls the router directly rather than over HTTP, there is no network hop at all on the server. The data is serialised into the HTML, and UserList hydrates with isPending already false. The void is deliberate: not awaiting means the page can stream while the query resolves.

Batching, streaming and subscriptions

httpBatchLink collects every tRPC call that happens in the same tick and sends them as one request. Render six components that each want their own query and you get one round trip instead of six. It is on by default in most setups and it quietly removes a whole category of performance problem.

Its downside is head-of-line blocking: the batch resolves when the slowest procedure in it resolves. httpBatchStreamLink fixes that by streaming each result as it lands.

import { createTRPCClient, httpBatchStreamLink } from '@trpc/client';

createTRPCClient<AppRouter>({
  links: [httpBatchStreamLink({ url: '/api/trpc' })],
});

v11 also added subscriptions over Server-Sent Events, which is a big deal for anyone who was avoiding real-time features because they did not want a WebSocket server to operate. SSE is a plain HTTP response that stays open. Your existing route handler, your existing deployment, no second process.

And if your router tree has grown large enough to hurt cold starts, lazy defers loading a subtree until something calls into it:

import { lazy } from '@trpc/server';
import { router } from './trpc';

export const appRouter = router({
  users: lazy(() => import('./routers/users')),
  reports: lazy(() => import('./routers/reports').then((m) => m.reportsRouter)),
});

The types stay exactly as they were. Only the loading is deferred.

Validating what goes out

Input validation gets all the attention. Output validation is the one that saved me from a bad afternoon.

const userOutput = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

export const usersRouter = router({
  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .output(userOutput)
    .query(async ({ ctx, input }) => {
      return ctx.db.user.findUnique({ where: { id: input.id } });
    }),
});

Add a passwordHash column to the users table, forget that this procedure does a bare findUnique, and without .output() you have just published password hashes to every client. With it, the response fails validation and tRPC returns an INTERNAL_SERVER_ERROR instead: loudly, in your logs, rather than silently in the payload. It costs a little runtime and it is worth it on anything touching user records.

Testing without a server

Procedures are functions that take a context and an input, so you can call them directly. No listening port, no supertest, no fixture app.

import { appRouter } from '@/server/routers';

describe('users router', () => {
  it('creates a user', async () => {
    const caller = appRouter.createCaller({
      user: { id: '1', name: 'Test', role: 'admin' },
      db: mockDb,
    });

    const result = await caller.users.create({
      name: 'New User',
      email: 'test@example.com',
    });

    expect(result.name).toBe('New User');
  });

  it('refuses an anonymous delete', async () => {
    const caller = appRouter.createCaller({ user: null, db: mockDb });

    await expect(caller.users.delete({ id: '1' })).rejects.toThrow(
      'UNAUTHORIZED'
    );
  });
});

Middleware runs, so that second test genuinely exercises the auth path rather than mocking it away. There is also a createCallerFactory helper if you would rather build the caller once and reuse it across a suite.

Where it doesn’t fit

tRPC REST GraphQL
Type safety Inferred, no config Manual, or OpenAPI codegen Schema plus codegen
Schema artefact None OpenAPI (optional) SDL (required)
Code generation None Optional Usually required
Non-TS clients No Yes Yes
Best for Full-stack TS apps Public and partner APIs Large data graphs, many clients

The honest summary is that tRPC’s greatest strength and its only real weakness are the same property. Skipping the schema is what buys you the instant feedback, and it is also why nobody outside your TypeScript codebase can talk to the thing. If there is any chance of a public API later, either put REST in front of the same service layer, or accept that you will be writing an adapter.

What I’d watch for

Two things bother me, and neither is a reason not to use it.

The first is compile speed. Inference this deep is not free, and a router with a few hundred procedures will make tsc and your editor think about it. Splitting into sub-routers and keeping return types simple helps.

The second is that it encourages you to treat the API as an implementation detail, because for a while it is. Then someone wants a mobile app, and the “API” turns out to be four hundred procedures with no versioning, no documented contract, and Zod schemas as the only description of what anything accepts. Keep the business logic in plain functions and let procedures be thin wrappers over them. Future you will need those functions.

Next on my list is pointing tRPC at Server Actions rather than a route handler, since a lot of what I use mutations for could be a form post. I suspect the answer is “pick one”, but I would like to find out properly first.

‘Till next time!