tRPC: End-to-End Type Safety for APIs

Every backend engineer has felt the sting of a runtime type mismatch. You rename a field on the server, the client keeps referencing the old name, and the bug only surfaces when a user triggers the right code path in production. REST APIs solve this with OpenAPI specs and code generation. GraphQL uses its own schema definition language. Both approaches work, but they introduce an intermediate artifact that can drift out of sync with the actual implementation.

tRPC takes a fundamentally different approach. By leveraging TypeScript's type inference, it creates a direct type-level contract between your server procedures and client calls. There is no schema file, no code generation step, and no runtime overhead. You define a procedure on the server, and the client immediately knows the exact shape of every input and output. This article walks through a production-grade tRPC setup from scratch, covering router architecture, middleware composition, typed error handling, and seamless React Query integration.

How tRPC Creates Type-Level Contracts

Traditional API tooling requires an explicit contract layer between client and server. OpenAPI demands a YAML or JSON specification file. GraphQL requires a schema written in SDL. Both are powerful, but both add a translation step between what the code actually does and what the contract says it does. When that translation falls behind, you get subtle bugs.

tRPC eliminates this translation entirely. The server defines procedures using standard TypeScript functions with Zod validators for input. The client imports only the type of the router, not the implementation. TypeScript's structural type system then infers the exact input and output shapes at compile time.

// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

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

export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;

The initTRPC builder accepts a generic context type that flows through every procedure. This context carries request-specific data like the authenticated user, database connections, or tracing spans. Because the context type is generic, every middleware and procedure handler gets full type information without manual casting.

The key architectural insight is that tRPC procedures are just TypeScript functions with validated inputs and typed outputs. The framework adds the RPC transport layer on top, but the type contract is pure TypeScript inference. This means your IDE, your linter, and your CI pipeline all catch contract violations before deployment.

Router Architecture and Procedure Design

A well-structured tRPC router mirrors your domain model. Each domain area gets its own sub-router, and the main app router merges them into a single type-safe tree. This keeps individual files manageable while maintaining a single type export for the client.

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

export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id },
        select: { id: true, name: true, email: true, avatarUrl: true },
      });
      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `User ${input.id} not found`,
        });
      }
      return user;
    }),

  updateProfile: protectedProcedure
    .input(z.object({
      name: z.string().min(1).max(100),
      bio: z.string().max(500).optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.db.user.update({
        where: { id: ctx.user.id },
        data: input,
      });
    }),

  list: publicProcedure
    .input(z.object({
      cursor: z.string().uuid().optional(),
      limit: z.number().min(1).max(50).default(20),
    }))
    .query(async ({ ctx, input }) => {
      const items = await ctx.db.user.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        orderBy: { createdAt: 'desc' },
      });
      let nextCursor: string | undefined;
      if (items.length > input.limit) {
        const next = items.pop();
        nextCursor = next?.id;
      }
      return { items, nextCursor };
    }),
});

Notice the pattern: each procedure starts with an input validator, then chains into either a .query() for reads or a .mutation() for writes. This distinction matters for React Query integration because queries are cached and automatically refetched, while mutations trigger cache invalidation.

The app router merges sub-routers and exports the combined type for the client to consume:

// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
import { commentRouter } from './comment';

export const appRouter = router({
  user: userRouter,
  post: postRouter,
  comment: commentRouter,
});

// This type is imported by the client — only the type, not the runtime code
export type AppRouter = typeof appRouter;

The AppRouter type is the single source of truth. The client imports it as a type-only import, meaning no server code is bundled into the client. TypeScript's type erasure ensures the contract exists only at compile time with zero runtime cost.

Middleware Composition and Context Enrichment

Middleware in tRPC is where cross-cutting concerns live: authentication, authorization, logging, rate limiting, and input sanitization. Unlike Express middleware that operates on raw request and response objects, tRPC middleware operates on typed contexts and returns typed results.

// server/middleware/auth.ts
import { TRPCError } from '@trpc/server';
import { middleware } from '../trpc';

const isAuthenticated = middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'You must be logged in to perform this action',
    });
  }
  return next({
    ctx: {
      user: ctx.session.user, // Narrows the type — user is now non-nullable
    },
  });
});

const hasRole = (role: 'admin' | 'editor' | 'viewer') =>
  middleware(async ({ ctx, next }) => {
    if (!ctx.session?.user) {
      throw new TRPCError({ code: 'UNAUTHORIZED' });
    }
    if (!ctx.session.user.roles.includes(role)) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: `Required role: ${role}`,
      });
    }
    return next({
      ctx: { user: ctx.session.user },
    });
  });

export const protectedProcedure = publicProcedure.use(isAuthenticated);
export const adminProcedure = publicProcedure.use(hasRole('admin'));

The critical detail here is context narrowing. When isAuthenticated middleware calls next() with a new context, the downstream procedure sees ctx.user as a guaranteed non-nullable value. This is not a runtime assertion; it is a compile-time type narrowing. If you try to access ctx.user in a publicProcedure, TypeScript will correctly flag it as potentially undefined.

Middleware composition follows a pipeline pattern. You can chain multiple middleware steps, and each one can enrich or narrow the context for the next. This creates a clear separation of concerns while maintaining full type safety through the entire chain.

// Composing middleware for audit logging
const withAuditLog = middleware(async ({ ctx, next, path, type }) => {
  const start = performance.now();
  const result = await next();
  const duration = performance.now() - start;

  await ctx.auditLog.record({
    userId: ctx.user?.id,
    procedure: path,
    type,
    duration,
    timestamp: new Date(),
  });

  return result;
});

export const auditedProcedure = protectedProcedure.use(withAuditLog);

React Query Integration

The @trpc/react-query package wraps TanStack Query with tRPC's type information, giving you fully typed hooks for every procedure. Setting it up requires a client provider that wraps your React application.

// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 5 * 60 * 1000,
        retry: (failureCount, error) => {
          // Don't retry on 4xx errors
          if (error.data?.httpStatus && error.data.httpStatus < 500) {
            return false;
          }
          return failureCount < 3;
        },
      },
    },
  }));

  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          headers: () => ({
            'x-trpc-source': 'react',
          }),
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

With the provider in place, calling procedures from components is straightforward. The trpc.user.getById.useQuery() hook returns a fully typed result, and your IDE will autocomplete every field on the returned user object.

// components/UserProfile.tsx
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = trpc.user.getById.useQuery(
    { id: userId },
    { enabled: !!userId }
  );

  const utils = trpc.useUtils();
  const updateProfile = trpc.user.updateProfile.useMutation({
    onSuccess: () => {
      // Invalidate the cache so the profile refetches
      utils.user.getById.invalidate({ id: userId });
    },
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorDisplay error={error} />;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <button onClick={() => updateProfile.mutate({ name: 'New Name' })}>
        Update
      </button>
    </div>
  );
}

The httpBatchLink is significant for performance. Instead of sending individual HTTP requests for each procedure call, tRPC batches multiple calls made within the same render cycle into a single HTTP request. The server processes them in parallel and returns all results at once. For pages that make five or six API calls on mount, this can reduce network round trips dramatically.

For infinite scrolling lists, tRPC integrates cleanly with TanStack Query's useInfiniteQuery:

function UserList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    trpc.user.list.useInfiniteQuery(
      { limit: 20 },
      {
        getNextPageParam: (lastPage) => lastPage.nextCursor,
      }
    );

  const users = data?.pages.flatMap((page) => page.items) ?? [];

  return (
    <div>
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Error Handling and Validation

tRPC provides structured error handling through the TRPCError class and Zod validation. Errors are categorized by HTTP-like codes (UNAUTHORIZED, NOT_FOUND, BAD_REQUEST, INTERNAL_SERVER_ERROR) which map to actual HTTP status codes in the transport layer.

The first layer of error handling is automatic: Zod validation errors are caught by tRPC and returned as BAD_REQUEST errors with detailed field-level messages. If a client sends { id: 123 } when the schema expects { id: z.string().uuid() }, the error response includes exactly which field failed and why.

// Custom error formatting for consistent API responses
const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
        // Attach request ID for tracing
        requestId: error.cause?.requestId,
      },
    };
  },
});

// Client-side error handling with type narrowing
function handleTRPCError(error: TRPCClientError<AppRouter>) {
  if (error.data?.zodError) {
    // Input validation failed — show field errors
    const fieldErrors = error.data.zodError.fieldErrors;
    return Object.entries(fieldErrors).map(
      ([field, messages]) => `${field}: ${messages?.join(', ')}`
    );
  }

  switch (error.data?.code) {
    case 'UNAUTHORIZED':
      redirectToLogin();
      break;
    case 'FORBIDDEN':
      showToast('You do not have permission for this action');
      break;
    case 'NOT_FOUND':
      show404Page();
      break;
    default:
      showToast('Something went wrong. Please try again.');
      reportToSentry(error);
  }
}

For domain-specific errors, you can create typed error classes that carry structured metadata. This is especially useful when combined with Effect-style error channels where you want the caller to exhaustively handle every possible failure mode.

// Typed domain errors
class InsufficientBalanceError extends TRPCError {
  constructor(public readonly required: number, public readonly available: number) {
    super({
      code: 'BAD_REQUEST',
      message: `Insufficient balance: required ${required}, available ${available}`,
    });
  }
}

// In the procedure
.mutation(async ({ ctx, input }) => {
  const balance = await ctx.billing.getBalance(ctx.user.id);
  if (balance < input.amount) {
    throw new InsufficientBalanceError(input.amount, balance);
  }
  // proceed with transaction
})

Testing tRPC Procedures

One of tRPC's underappreciated strengths is testability. Because procedures are just functions that accept a context and input, you can test them directly without spinning up an HTTP server. This makes unit tests fast and focused.

// __tests__/user.test.ts
import { createCaller } from '../server/routers/_app';
import { createTestContext } from './helpers';

describe('user router', () => {
  it('returns user by id', async () => {
    const ctx = createTestContext({
      db: mockDb,
      session: { user: { id: 'test-user', roles: ['viewer'] } },
    });

    const caller = createCaller(ctx);
    const user = await caller.user.getById({ id: 'user-123' });

    expect(user).toEqual({
      id: 'user-123',
      name: 'Test User',
      email: '[email protected]',
      avatarUrl: null,
    });
  });

  it('throws NOT_FOUND for missing user', async () => {
    const ctx = createTestContext({ db: emptyMockDb });
    const caller = createCaller(ctx);

    await expect(
      caller.user.getById({ id: 'nonexistent' })
    ).rejects.toThrow('NOT_FOUND');
  });

  it('requires authentication for profile update', async () => {
    const ctx = createTestContext({ db: mockDb, session: null });
    const caller = createCaller(ctx);

    await expect(
      caller.user.updateProfile({ name: 'New Name' })
    ).rejects.toThrow('UNAUTHORIZED');
  });
});

The createCaller utility creates a type-safe function that invokes procedures directly, bypassing the HTTP layer. Your tests get the same type safety as your application code, so a renamed field will cause a compile error in both your component and your test file.

For integration tests that need to verify the full HTTP round trip, including serialization and batching, you can use a Playwright-based testing setup that exercises the actual tRPC transport layer against a running server.

Production Deployment Patterns

Deploying tRPC to production involves decisions around transport configuration, error reporting, and performance optimization. Here are the patterns that hold up under real traffic.

First, configure the HTTP adapter for your framework. tRPC supports Express, Fastify, Next.js, and standalone Node.js servers. Each adapter handles request parsing and response formatting differently, but the procedure logic remains identical.

// Standalone Node.js adapter with production config
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { appRouter } from './routers/_app';
import { createContext } from './context';

const server = createHTTPServer({
  router: appRouter,
  createContext,
  responseMeta({ paths, errors, type }) {
    // Cache GET queries that don't error
    if (type === 'query' && errors.length === 0) {
      const allPublic = paths?.every((path) =>
        path.startsWith('public.')
      );
      if (allPublic) {
        return {
          headers: {
            'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
          },
        };
      }
    }
    return {};
  },
  onError({ error, path }) {
    if (error.code === 'INTERNAL_SERVER_ERROR') {
      Sentry.captureException(error, {
        tags: { trpcPath: path },
      });
    }
  },
});

server.listen(3000);

For high-traffic applications, consider using the WebSocket adapter for subscriptions and the HTTP adapter for queries and mutations. This hybrid approach gives you real-time updates where needed without the overhead of maintaining WebSocket connections for simple CRUD operations.

Rate limiting integrates naturally through middleware. Because tRPC middleware has access to the full context including the client IP and authenticated user, you can implement granular rate limits per procedure, per user, or per IP range:

const rateLimited = (maxRequests: number, windowMs: number) =>
  middleware(async ({ ctx, next, path }) => {
    const key = `ratelimit:${ctx.user?.id ?? ctx.ip}:${path}`;
    const current = await ctx.redis.incr(key);

    if (current === 1) {
      await ctx.redis.expire(key, windowMs / 1000);
    }

    if (current > maxRequests) {
      throw new TRPCError({
        code: 'TOO_MANY_REQUESTS',
        message: `Rate limit exceeded. Try again in ${windowMs / 1000} seconds.`,
      });
    }

    return next();
  });

Monitoring tRPC in production pairs well with OpenTelemetry distributed tracing. Each procedure call becomes a span in your trace, with the procedure path as the span name and input parameters as attributes. This gives you per-procedure latency percentiles, error rates, and call volumes without custom instrumentation for each endpoint.

Finally, consider versioning strategy. tRPC does not enforce API versioning out of the box, which is intentional. Since both client and server share the same TypeScript types, breaking changes are caught at compile time. For public APIs consumed by third-party clients that do not share your TypeScript codebase, tRPC is not the right tool. Use it for your internal full-stack TypeScript applications, and expose public APIs through OpenAPI or GraphQL.