DEV Community

Wilson Xu
Wilson Xu

Posted on

tRPC: End-to-End Type Safety Without the GraphQL Complexity

tRPC: End-to-End Type Safety Without the GraphQL Complexity

By Wilson Xu
Target: Smashing Magazine / LogRocket / Honeybadger (~2,800 words)


Introduction

There is a particular kind of pain that every TypeScript developer knows. You carefully type your backend response — every field, every nested object, every nullable edge case. Then you call the API from your frontend, and suddenly you're back in the land of any. Your carefully crafted types stop at the HTTP boundary. Welcome to type drift.

The traditional solutions are unsatisfying. REST APIs require manual synchronization between server and client types, usually via OpenAPI specs or hand-written shared type packages. GraphQL solves the schema problem elegantly but introduces a whole ecosystem of complexity: schema definitions, resolvers, codegen pipelines, and a runtime query language to learn and maintain.

tRPC takes a different approach. It says: if your frontend and backend are both TypeScript (which they increasingly are in Next.js, Remix, or monorepo setups), why introduce an intermediate representation at all? Just share the types directly.

This article is a complete guide to tRPC. We will build a real application from scratch, cover every major feature, and end with an honest decision guide for when to use tRPC versus GraphQL versus plain REST.


1. The Problem: REST APIs and Type Drift

Consider a typical Next.js application. Your API route looks like this:

// pages/api/user/[id].ts (or app/api/user/[id]/route.ts)
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const user = await db.user.findUnique({ where: { id: params.id } });
  return Response.json(user);
}
Enter fullscreen mode Exit fullscreen mode

And your frontend component fetches it like this:

// app/profile/page.tsx
const res = await fetch(`/api/user/${userId}`);
const user = await res.json(); // type: any
Enter fullscreen mode Exit fullscreen mode

The moment you call res.json(), TypeScript gives up. You might add a type assertion (as User), but that's just lying to the compiler. If you rename a field on the backend, TypeScript will not catch it. You will find out at runtime — usually in production, usually from a user.

The standard fix is to extract a shared types package:

// packages/types/src/user.ts
export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: string; // Date serialized to string in JSON
}
Enter fullscreen mode Exit fullscreen mode

This works, but it requires discipline. The type definition and the actual data shape can diverge. The createdAt: string comment is already a smell — your database returns a Date object, JSON serializes it to a string, and your frontend has to remember to new Date() it. Every edge case like this is a potential runtime bug.

tRPC eliminates this entire class of problems.


2. What tRPC Is (and Isn't)

tRPC (TypeScript Remote Procedure Call) is a library that lets you build fully type-safe APIs without schemas or code generation. It uses TypeScript's type inference to carry type information from your server functions directly to your client calls.

What tRPC is:

  • A way to call server functions from the client with full TypeScript types
  • A thin RPC layer over HTTP (uses standard HTTP under the hood)
  • Framework-agnostic (works with Next.js, Express, Fastify, SvelteKit, etc.)
  • Deeply integrated with React Query on the client side

What tRPC is not:

  • A replacement for GraphQL's introspection, federation, or multi-client API capabilities
  • An ORM or database layer
  • Suitable for public APIs consumed by non-TypeScript clients
  • A REST API (though it can coexist with one)

The key insight is that tRPC works best when your client and server live in the same repository (or monorepo) and are both TypeScript. The types are shared at build time, not over the wire. There is no runtime overhead from a type system.


3. Setting Up tRPC with Next.js App Router

Let us build a simple task management API. Start with a fresh Next.js project:

npx create-next-app@latest trpc-demo --typescript --tailwind --app
cd trpc-demo
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod
Enter fullscreen mode Exit fullscreen mode

The package breakdown:

  • @trpc/server — core server-side tRPC
  • @trpc/client — vanilla client (used internally)
  • @trpc/react-query — React Query integration (the hooks you actually use in components)
  • @trpc/next — Next.js adapter
  • @tanstack/react-query — peer dependency
  • zod — schema validation (nearly universal in tRPC codebases)

Create the tRPC instance:

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

// This is where you define what's available in context (covered in section 7)
export const createTRPCContext = async (opts: { headers: Headers }) => {
  return {
    headers: opts.headers,
    // Add: session, db, user, etc.
  };
};

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

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

// Export reusable router and procedure helpers
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
Enter fullscreen mode Exit fullscreen mode

Create the API 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({ headers: req.headers }),
  });

export { handler as GET, handler as POST };
Enter fullscreen mode Exit fullscreen mode

Set up the client-side provider:

// src/trpc/react.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import { useState } from 'react';
import type { AppRouter } from '~/server/routers/_app';

export const api = createTRPCReact<AppRouter>();

function getBaseUrl() {
  if (typeof window !== 'undefined') return '';
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return `http://localhost:${process.env.PORT ?? 3000}`;
}

export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    api.createClient({
      links: [
        loggerLink({ enabled: (opts) => process.env.NODE_ENV === 'development' }),
        httpBatchLink({ url: `${getBaseUrl()}/api/trpc` }),
      ],
    })
  );

  return (
    <api.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </api.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wrap your root layout with this provider:

// src/app/layout.tsx
import { TRPCReactProvider } from '~/trpc/react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <TRPCReactProvider>{children}</TRPCReactProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

4. Defining Routers and Procedures

A tRPC router is a collection of procedures (think: endpoints). Routers can be nested to organize a large API.

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

// Simulate a database
const tasks: Array<{ id: string; title: string; completed: boolean; createdAt: Date }> = [];

export const taskRouter = router({
  getAll: publicProcedure.query(() => {
    return tasks;
  }),

  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => {
      const task = tasks.find((t) => t.id === input.id);
      if (!task) throw new TRPCError({ code: 'NOT_FOUND' });
      return task;
    }),

  create: publicProcedure
    .input(z.object({ title: z.string().min(1).max(200) }))
    .mutation(({ input }) => {
      const task = {
        id: crypto.randomUUID(),
        title: input.title,
        completed: false,
        createdAt: new Date(),
      };
      tasks.push(task);
      return task;
    }),

  toggle: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      const task = tasks.find((t) => t.id === input.id);
      if (!task) throw new TRPCError({ code: 'NOT_FOUND' });
      task.completed = !task.completed;
      return task;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      const index = tasks.findIndex((t) => t.id === input.id);
      if (index === -1) throw new TRPCError({ code: 'NOT_FOUND' });
      tasks.splice(index, 1);
      return { success: true };
    }),
});
Enter fullscreen mode Exit fullscreen mode

Merge routers in a root _app router:

// src/server/routers/_app.ts
import { router } from '~/server/trpc';
import { taskRouter } from './task';
import { userRouter } from './user';

export const appRouter = router({
  task: taskRouter,
  user: userRouter,
});

// Export the type — this is what the client uses for type inference
export type AppRouter = typeof appRouter;
Enter fullscreen mode Exit fullscreen mode

Notice AppRouter is a type export, not a value. The entire type of your API — every procedure, every input, every output — is inferred from your code. No codegen step. No schema file to maintain.


5. Queries vs Mutations vs Subscriptions

tRPC has three procedure types, mirroring familiar concepts:

Queries — read data, use HTTP GET (when using httpLink), cacheable:

// Server
getProfile: publicProcedure
  .input(z.object({ userId: z.string() }))
  .query(async ({ input, ctx }) => {
    return await ctx.db.user.findUnique({ where: { id: input.userId } });
  }),

// Client
const { data, isLoading, error } = api.user.getProfile.useQuery({ userId: '123' });
Enter fullscreen mode Exit fullscreen mode

Mutations — write data, use HTTP POST, not cached:

// Server
updateProfile: protectedProcedure
  .input(z.object({ name: z.string(), bio: z.string().optional() }))
  .mutation(async ({ input, ctx }) => {
    return await ctx.db.user.update({
      where: { id: ctx.session.userId },
      data: input,
    });
  }),

// Client
const updateProfile = api.user.updateProfile.useMutation({
  onSuccess: () => {
    void utils.user.getProfile.invalidate();
  },
});

// Call it
updateProfile.mutate({ name: 'New Name', bio: 'Updated bio' });
Enter fullscreen mode Exit fullscreen mode

Subscriptions — real-time updates over WebSocket or SSE. These require a WebSocket adapter:

// Server (requires @trpc/server with WebSocket support)
onTaskUpdate: publicProcedure
  .input(z.object({ taskId: z.string() }))
  .subscription(async function* ({ input, signal }) {
    // Async generator yields values to the client
    while (!signal?.aborted) {
      const update = await waitForTaskUpdate(input.taskId);
      yield update;
    }
  }),

// Client
api.task.onTaskUpdate.useSubscription(
  { taskId: '123' },
  {
    onData: (data) => console.log('Task updated:', data),
    onError: (err) => console.error(err),
  }
);
Enter fullscreen mode Exit fullscreen mode

Subscriptions require more infrastructure (WebSocket server, or using the SSE link) and are best suited for truly real-time features like live collaboration or notifications. For most use cases, query invalidation after mutations is sufficient.


6. Input Validation with Zod

Zod is the de facto standard for tRPC input validation, and the integration is seamless. Zod schemas serve double duty: they validate input at runtime and infer TypeScript types at compile time.

import { z } from 'zod';

// Define reusable schemas
const TaskSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
  completed: z.boolean(),
  priority: z.enum(['low', 'medium', 'high']).default('medium'),
  dueDate: z.string().datetime().optional(),
  tags: z.array(z.string()).max(10).default([]),
});

const CreateTaskInput = TaskSchema.omit({ id: true, completed: true });
const UpdateTaskInput = TaskSchema.partial().required({ id: true });

// Use in procedures
createTask: publicProcedure
  .input(CreateTaskInput)
  .mutation(async ({ input }) => {
    // input is fully typed: { title: string, priority: 'low'|'medium'|'high', dueDate?: string, tags: string[] }
    const task = await db.task.create({ data: input });
    return task;
  }),

updateTask: publicProcedure
  .input(UpdateTaskInput)
  .mutation(async ({ input }) => {
    const { id, ...data } = input;
    return await db.task.update({ where: { id }, data });
  }),
Enter fullscreen mode Exit fullscreen mode

When validation fails, tRPC automatically returns a structured error. Because we configured errorFormatter in our tRPC instance to include zodError, the client receives flattened field errors:

const createTask = api.task.createTask.useMutation();

// If title is empty, the error will include:
// error.data.zodError.fieldErrors.title = ['Title is required']
if (createTask.error?.data?.zodError) {
  const fieldErrors = createTask.error.data.zodError.fieldErrors;
  // Render inline form errors
}
Enter fullscreen mode Exit fullscreen mode

7. Context and Middleware (Auth, Logging)

Context is the mechanism for injecting per-request data (database client, authenticated user, request headers) into your procedures. Middleware lets you compose reusable logic.

Creating a protected procedure:

// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { getServerSession } from 'next-auth';
import { db } from '~/server/db';

export const createTRPCContext = async (opts: { headers: Headers }) => {
  const session = await getServerSession();
  return {
    db,
    session,
    headers: opts.headers,
  };
};

type Context = Awaited<ReturnType<typeof createTRPCContext>>;
const t = initTRPC.context<Context>().create();

// Middleware that enforces authentication
const isAuthenticated = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  // Pass the session down as non-null
  return next({
    ctx: {
      ...ctx,
      session: ctx.session, // TypeScript now knows session is defined
    },
  });
});

// Timing middleware for logging
const timingMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
  console.log(`[${type}] ${path} - ${duration}ms`);
  return result;
});

// Compose middlewares
export const publicProcedure = t.procedure.use(timingMiddleware);
export const protectedProcedure = t.procedure
  .use(timingMiddleware)
  .use(isAuthenticated);
Enter fullscreen mode Exit fullscreen mode

Now procedures using protectedProcedure automatically have access to ctx.session.user with full type safety — TypeScript knows the session cannot be null inside a protected procedure.

// src/server/routers/task.ts
import { protectedProcedure } from '~/server/trpc';

createTask: protectedProcedure
  .input(CreateTaskInput)
  .mutation(async ({ input, ctx }) => {
    // ctx.session.user is guaranteed non-null here
    return await ctx.db.task.create({
      data: {
        ...input,
        userId: ctx.session.user.id, // TypeScript is happy
      },
    });
  }),
Enter fullscreen mode Exit fullscreen mode

8. Error Handling with TRPCError

tRPC has a structured error system built around TRPCError. Each error has a code that maps to an HTTP status code and a client-readable category.

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

getTask: protectedProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input, ctx }) => {
    const task = await ctx.db.task.findUnique({
      where: { id: input.id },
    });

    if (!task) {
      throw new TRPCError({
        code: 'NOT_FOUND',
        message: `Task with id ${input.id} not found`,
      });
    }

    if (task.userId !== ctx.session.user.id) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'You do not have permission to view this task',
      });
    }

    return task;
  }),
Enter fullscreen mode Exit fullscreen mode

Common error codes and their HTTP equivalents:

tRPC Code HTTP Status Use Case
BAD_REQUEST 400 Invalid input not caught by Zod
UNAUTHORIZED 401 Not logged in
FORBIDDEN 403 Logged in but lacking permission
NOT_FOUND 404 Resource does not exist
CONFLICT 409 Duplicate resource
PRECONDITION_FAILED 412 Business logic violation
INTERNAL_SERVER_ERROR 500 Unexpected server error
TIMEOUT 408 Operation took too long

On the client, errors are typed and structured:

const { data, error } = api.task.getTask.useQuery({ id: taskId });

if (error) {
  if (error.data?.code === 'NOT_FOUND') {
    return <div>Task not found.</div>;
  }
  if (error.data?.code === 'FORBIDDEN') {
    return <div>Access denied.</div>;
  }
  return <div>Something went wrong: {error.message}</div>;
}
Enter fullscreen mode Exit fullscreen mode

For unexpected errors, tRPC automatically catches thrown exceptions and wraps them in INTERNAL_SERVER_ERROR. You can hook into this with the onError option in your route handler to log to your error tracking service:

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createTRPCContext({ headers: req.headers }),
    onError({ error, path, type }) {
      if (error.code === 'INTERNAL_SERVER_ERROR') {
        // Send to Honeybadger, Sentry, etc.
        Honeybadger.notify(error, { context: { path, type } });
      }
    },
  });
Enter fullscreen mode Exit fullscreen mode

9. React Query Integration

tRPC's React integration wraps React Query, so you get all of React Query's power (caching, background refetching, optimistic updates, infinite queries) with full type safety.

Optimistic updates for instant UI feedback:

function TaskItem({ task }: { task: Task }) {
  const utils = api.useUtils();

  const toggleTask = api.task.toggle.useMutation({
    onMutate: async ({ id }) => {
      // Cancel any outgoing refetches
      await utils.task.getAll.cancel();

      // Snapshot previous value
      const previous = utils.task.getAll.getData();

      // Optimistically update the cache
      utils.task.getAll.setData(undefined, (old) =>
        old?.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
      );

      return { previous };
    },
    onError: (_, __, context) => {
      // Roll back on error
      utils.task.getAll.setData(undefined, context?.previous);
    },
    onSettled: () => {
      // Always refetch after mutation
      void utils.task.getAll.invalidate();
    },
  });

  return (
    <div onClick={() => toggleTask.mutate({ id: task.id })}>
      {task.title}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Infinite queries for pagination:

// Server
getTasks: publicProcedure
  .input(z.object({
    limit: z.number().min(1).max(100).default(20),
    cursor: z.string().optional(),
  }))
  .query(async ({ input }) => {
    const tasks = await db.task.findMany({
      take: input.limit + 1, // Fetch one extra to know if there's a next page
      cursor: input.cursor ? { id: input.cursor } : undefined,
      orderBy: { createdAt: 'desc' },
    });
    const nextCursor = tasks.length > input.limit ? tasks.pop()!.id : undefined;
    return { tasks, nextCursor };
  }),

// Client
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
  api.task.getTasks.useInfiniteQuery(
    { limit: 20 },
    {
      getNextPageParam: (lastPage) => lastPage.nextCursor,
      initialCursor: undefined,
    }
  );

const allTasks = data?.pages.flatMap((page) => page.tasks) ?? [];
Enter fullscreen mode Exit fullscreen mode

Server-side rendering with the App Router:

For RSC (React Server Components), you can call tRPC procedures directly without the HTTP overhead:

// src/trpc/server.ts
import { createCaller } from '@trpc/server';
import { createTRPCContext } from '~/server/trpc';
import { appRouter } from '~/server/routers/_app';
import { headers } from 'next/headers';

export const api = createCaller(
  async () => createTRPCContext({ headers: headers() })
);

// Use in a Server Component
// app/tasks/page.tsx
import { api } from '~/trpc/server';

export default async function TasksPage() {
  const tasks = await api.task.getAll(); // Direct function call, no HTTP
  return <TaskList tasks={tasks} />;
}
Enter fullscreen mode Exit fullscreen mode

10. tRPC vs GraphQL vs REST — Decision Guide

After building with all three, here is an honest comparison:

Choose tRPC when:

  • Your client and server are both TypeScript in the same repo or monorepo
  • You want zero-config type safety without codegen
  • Your team is small and speed of development matters
  • You are building internal tools, dashboards, or single-tenant SaaS
  • You use Next.js, Remix, or a similar full-stack framework

Choose GraphQL when:

  • You have multiple clients with different data needs (mobile, web, third-party)
  • You need schema introspection for tooling, documentation, or exploration
  • You require fine-grained field selection to avoid overfetching at scale
  • You need federation across multiple backend services
  • You have a public API that external developers will consume

Choose REST when:

  • You need a public API that non-TypeScript clients will consume
  • You require HTTP caching semantics at the CDN level
  • You are integrating with third-party tooling that expects REST conventions
  • Your team has established REST conventions and the migration cost is not justified
  • You need to expose webhooks or streaming endpoints

The honest reality is that tRPC and REST are not mutually exclusive. Many teams use tRPC for their internal application API and expose a separate REST API for webhooks or third-party integrations. tRPC also does not prevent you from adding standard REST routes alongside it.

Performance comparison: tRPC uses HTTP batching by default (multiple procedure calls in one HTTP request), React Query's caching, and can skip the HTTP layer entirely in RSC. GraphQL has a similar batching story with DataLoader. Plain REST requires manual orchestration of parallel requests. For most applications, the difference is negligible — architecture and caching strategy matter far more than the API style.

Developer experience: tRPC wins on DX for full-stack TypeScript teams. The feedback loop from changing a server function to seeing a type error in your component is near-instant. No running codegen, no opening a separate schema file. The procedure is the type and the implementation in one place.


Wrapping Up

tRPC solves a real problem — type drift at the API boundary — with an elegant solution: skip the boundary entirely when you control both sides. It is not trying to compete with GraphQL for API-first architectures or replace REST for public APIs. It is a sharp tool for a specific job: full-stack TypeScript applications where end-to-end type safety and developer experience are paramount.

The setup we covered — tRPC instance, context with authentication, Zod validation, React Query integration, error handling, and optimistic updates — represents a production-ready foundation. Add Prisma for the database layer and NextAuth for sessions, and you have a full-stack type-safe application with remarkably little boilerplate.

The best endorsement for tRPC is simply using it for a week on a real project. After you have experienced autocomplete that knows exactly what fields your server returns, and TypeScript errors that catch API contract violations before they hit production, going back to manual type synchronization feels like a step backward.


Wilson Xu is a full-stack engineer who writes about TypeScript, developer tooling, and building production systems. Find him on GitHub at @chengyixu.


Word count: ~2,800 words
Code examples: 18
Topics covered: REST type drift, tRPC concepts, Next.js App Router setup, routers/procedures, queries/mutations/subscriptions, Zod validation, middleware/auth, error handling, React Query integration, comparison guide

Top comments (0)