1. Home
  2. tRPC: Building End-to-End Type-Safe APIs Without Code Generation

tRPC: Building End-to-End Type-Safe APIs Without Code Generation

Introduction

TypeScript has transformed type safety on the frontend, but APIs have long been the weakest link in the full-stack type chain. REST APIs require manual type definitions that drift from the server implementation over time. GraphQL solves part of this but introduces schema files and code generation pipelines that add friction to everyday development.

tRPC takes a fundamentally different approach: if both your client and server are TypeScript, why define an interface contract at all? Instead of a schema, tRPC uses TypeScript's structural type inference to automatically propagate types from your router definitions directly to your client code β€” no schemas, no codegen steps, no separate type-sync scripts to maintain.

This article covers tRPC's architecture, how to integrate it with a Next.js App Router project, building type-safe procedures with Zod validation, authentication middleware, real-time subscriptions, and unit-testing procedures without spinning up a server.

Understanding tRPC's Architecture

How Type Inference Flows End-to-End

tRPC's core insight is that a TypeScript function's input and output types already constitute a complete API contract. The library captures those types at the router level and exports them as a single AppRouter type that the client imports directly β€” at the type level only.

flowchart LR A["Server: Router Definition\n(input/output types)"] -->|"Exports AppRouter type"| B["Type-only import\n(zero runtime bytes)"] B --> C["Client: trpc.post.list.useQuery(...)"] C -->|"Full IntelliSense"| D["Autocomplete &\ncompile-time errors"]

The critical detail: only the type of AppRouter crosses the client boundary. The router implementation code never ships to the browser. You get full type coverage with zero bundle-size cost.

Procedures: The Core Primitive

Every tRPC API is built from three procedure types:

Procedure HTTP Equivalent Use Case
query GET Read data, safe to cache
mutation POST/PUT/DELETE Writes or side-effect operations
subscription WebSocket Real-time streaming data

Procedures are grouped into routers, which can be nested to create namespaced APIs that mirror your domain model.

When tRPC Makes Sense (and When It Doesn't)

tRPC is a strong fit when:

  • Both client and server are TypeScript (monorepo or shared types are feasible)
  • You want instant type feedback with no build step between server and client changes
  • Your team doesn't need to expose the API to external consumers

Prefer REST or GraphQL when:

  • You need a public API consumed by third-party clients
  • You have non-TypeScript clients (mobile apps, external services)
  • Your team needs field-level data fetching or a graph traversal model

Setting Up tRPC with Next.js App Router

Installation and Project Structure

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

A recommended layout for a Next.js project:

src/
β”œβ”€β”€ server/
β”‚   β”œβ”€β”€ trpc.ts              # tRPC instance, context, and middleware
β”‚   └── routers/
β”‚       β”œβ”€β”€ _app.ts          # Root router β€” merges all sub-routers
β”‚       β”œβ”€β”€ post.ts
β”‚       └── user.ts
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ api/trpc/[trpc]/
β”‚   β”‚   └── route.ts         # Next.js App Router HTTP handler
β”‚   └── _trpc/
β”‚       └── client.ts        # Client-side tRPC hooks

Creating the tRPC Instance

The initTRPC call establishes your context type and is the foundation everything else builds on.

// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { type NextRequest } from 'next/server';
import { ZodError } from 'zod';
import superjson from 'superjson';

// Context is created fresh on every request
export const createTRPCContext = async (opts: { req: NextRequest }) => {
  const session = await getSessionFromRequest(opts.req);
  return {
    req: opts.req,
    session,
    db, // your database client (Prisma, Drizzle, etc.)
  };
};

type Context = Awaited<ReturnType<typeof createTRPCContext>>;

const t = initTRPC.context<Context>().create({
  transformer: superjson, // handles Date, Map, Set over JSON transparently
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        // Surface Zod validation errors to the client
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

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

// Middleware that enforces authentication
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      ...ctx,
      // Re-typed so downstream procedures see a non-nullable session
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

Wiring Up the Next.js App Router Handler

// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '~/server/routers/_app';
import { createTRPCContext } from '~/server/trpc';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createTRPCContext({ req: req as any }),
    onError:
      process.env.NODE_ENV === 'development'
        ? ({ path, error }) =>
            console.error(`tRPC error on ${path ?? '<no-path>'}:`, error)
        : undefined,
  });

export { handler as GET, handler as POST };

Building Type-Safe Procedures

Input Validation with Zod

Zod schemas serve as both runtime validators and compile-time TypeScript type sources simultaneously β€” you write the schema once and get both for free.

// src/server/routers/post.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  published: z.boolean().default(false),
  tags: z.array(z.string()).max(5).optional(),
});

export const postRouter = router({
  // Paginated list β€” open to all
  list: publicProcedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(20),
        cursor: z.string().optional(), // cursor-based pagination
      })
    )
    .query(async ({ ctx, input }) => {
      const posts = await ctx.db.post.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        orderBy: { createdAt: 'desc' },
        where: { published: true },
      });

      let nextCursor: string | undefined;
      if (posts.length > input.limit) {
        const nextItem = posts.pop();
        nextCursor = nextItem?.id;
      }

      return { posts, nextCursor };
    }),

  // Single post by slug
  bySlug: publicProcedure
    .input(z.object({ slug: z.string() }))
    .query(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({ where: { slug: input.slug } });
      if (!post) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' });
      }
      return post;
    }),

  // Create post β€” requires auth
  create: protectedProcedure
    .input(createPostSchema)
    .mutation(async ({ ctx, input }) => {
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.session.user.id,
          slug: slugify(input.title),
        },
      });
    }),
});

Merging Routers into the App Router

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

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

// This single type is the only thing the client imports
export type AppRouter = typeof appRouter;

Consuming tRPC on the Client

Setting Up Providers

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

export const trpc = createTRPCReact<AppRouter>();
// src/app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from './_trpc/client';
import superjson from 'superjson';

export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({ enabled: () => process.env.NODE_ENV === 'development' }),
        httpBatchLink({
          // Batches concurrent requests into a single HTTP round-trip
          url: '/api/trpc',
          transformer: superjson,
        }),
      ],
    })
  );

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

Queries and Mutations in Components

// src/app/posts/page.tsx
'use client';

import { trpc } from '../_trpc/client';

export default function PostsPage() {
  const utils = trpc.useUtils();

  const { data, fetchNextPage, hasNextPage, isLoading } =
    trpc.post.list.useInfiniteQuery(
      { limit: 10 },
      { getNextPageParam: (lastPage) => lastPage.nextCursor }
    );

  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      // Invalidate the list cache so new post appears immediately
      utils.post.list.invalidate();
    },
  });

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

  return (
    <div>
      {data?.pages.flatMap((page) => page.posts).map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content.slice(0, 150)}…</p>
        </article>
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load more</button>
      )}
      <button
        onClick={() =>
          createPost.mutate({ title: 'Hello tRPC', content: 'First tRPC post content here.' })
        }
      >
        New Post
      </button>
    </div>
  );
}

TypeScript errors at compile time if you pass an invalid input type, misspell a procedure name, or access a non-existent field on the return value β€” all with zero hand-written type annotations.

Advanced tRPC Patterns

Real-Time Subscriptions

tRPC supports WebSocket subscriptions via the observable primitive for live updates:

// src/server/routers/notification.ts
import { observable } from '@trpc/server/observable';
import EventEmitter from 'events';

const ee = new EventEmitter();

export const notificationRouter = router({
  onNewMessage: protectedProcedure
    .input(z.object({ roomId: z.string() }))
    .subscription(({ input }) => {
      return observable<{ message: string; from: string }>((emit) => {
        const handler = (data: { message: string; from: string; roomId: string }) => {
          if (data.roomId === input.roomId) {
            emit.next({ message: data.message, from: data.from });
          }
        };

        ee.on('message', handler);
        // Return cleanup function β€” called on unsubscribe
        return () => ee.off('message', handler);
      });
    }),
});

On the client, subscribe with:

trpc.notification.onNewMessage.useSubscription(
  { roomId: 'room-1' },
  { onData: (msg) => console.log('New message:', msg) }
);

Calling Procedures in React Server Components

In Next.js App Router, Server Components can call procedures directly β€” no HTTP round-trip involved:

// src/app/posts/[slug]/page.tsx  (Server Component β€” no 'use client')
import { createCallerFactory } from '@trpc/server';
import { appRouter } from '~/server/routers/_app';
import { createTRPCContext } from '~/server/trpc';

const createCaller = createCallerFactory(appRouter);

export default async function PostPage({ params }: { params: { slug: string } }) {
  const ctx = await createTRPCContext({ req: /* server request */ } as any);
  const caller = createCaller(ctx);

  // Direct function call β€” middleware still runs, auth is still enforced
  const post = await caller.post.bySlug({ slug: params.slug });

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

This pattern avoids the overhead of an HTTP request and still runs all your middleware (auth checks, logging, etc.) because it calls the same procedure logic directly.

Testing Procedures Without an HTTP Server

The createCallerFactory API makes unit-testing procedures fast and straightforward:

// src/server/routers/post.test.ts
import { createCallerFactory } from '@trpc/server';
import { appRouter } from './_app';
import { createInnerTRPCContext } from '../trpc';

const createCaller = createCallerFactory(appRouter);

describe('post router', () => {
  it('returns paginated posts for public users', async () => {
    const caller = createCaller(createInnerTRPCContext({ session: null }));
    const result = await caller.post.list({ limit: 5 });

    expect(result.posts).toHaveLength(5);
    expect(result.nextCursor).toBeDefined();
  });

  it('throws UNAUTHORIZED when creating a post without a session', async () => {
    const caller = createCaller(createInnerTRPCContext({ session: null }));

    await expect(
      caller.post.create({ title: 'Test', content: 'Content here for testing.' })
    ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
  });

  it('creates a post for authenticated users', async () => {
    const caller = createCaller(
      createInnerTRPCContext({ session: { user: { id: 'user-1' } } })
    );

    const post = await caller.post.create({
      title: 'My First tRPC Post',
      content: 'This is the body of the post, long enough to pass validation.',
    });

    expect(post.authorId).toBe('user-1');
    expect(post.slug).toBe('my-first-trpc-post');
  });
});

Conclusion and Next Steps

tRPC is a pragmatic answer to a real pain point in TypeScript monorepos: the gap between what your server returns and what your client knows about it. By treating the router itself as the contract, you eliminate an entire category of runtime errors and keep your type definitions from drifting.

The tradeoffs are deliberate and honest β€” tRPC works best in closed systems where both sides are TypeScript. It is not a replacement for REST or GraphQL when you need to serve external consumers or non-TypeScript clients. But for product teams building TypeScript full-stack apps, the developer experience gains are substantial:

  • Instant autocomplete on every procedure input and return value
  • Compile-time errors when the server changes a response shape
  • No codegen step β€” type changes propagate immediately on save
  • Testable procedures without a running HTTP server
  • Seamless React Server Component integration for zero-cost server-side calls

A natural next step is integrating tRPC with Drizzle ORM or Prisma for a fully type-safe database layer, and combining it with NextAuth.js or Lucia for session management β€” both of which fit cleanly into the createTRPCContext pattern shown above.