tRPC Tutorial: Build Type-Safe APIs Without GraphQL (2026)

A practical guide to building end-to-end type-safe APIs with tRPC. Covers project setup with Next.js App Router, Zod input validation, React Query integration, authentication middleware, error handling, testing, and production deployment patterns.

In This Guide
  1. What Is tRPC and Why It Exists
  2. tRPC vs REST vs GraphQL
  3. Setting Up tRPC with Next.js App Router
  4. Creating Your First Router and Procedures
  5. Input Validation with Zod
  6. React Query Integration
  7. Authentication and Middleware
  8. Error Handling Patterns
  9. Testing tRPC Procedures
  10. Production Tips and Performance
  11. Related Developer Tools
  12. Frequently Asked Questions

Every full-stack TypeScript project hits the same problem: you define types on the server, then re-define them on the client. When the API contract changes, the compiler does not catch the mismatch. Runtime errors appear in production instead of red squiggles in your editor.

tRPC solves this by sharing types directly between your server and client through TypeScript inference. No code generation. No schema files. No runtime overhead for parsing query languages. You write a function on the server, and the client knows its input and output types automatically.

This guide walks through a complete tRPC setup with Next.js App Router, from installation to production deployment. Every code example is copy-pasteable and tested against tRPC v11.

Validate your TypeScript as you follow along. The TypeScript Playground lets you test type definitions and catch errors in your browser without any local setup.

What Is tRPC and Why It Exists

tRPC stands for TypeScript Remote Procedure Call. It is an open-source library that lets you build fully typed APIs where the server definition is the single source of truth for both input validation and response types.

The core idea is simple: you write a TypeScript function on the server. The client imports the function's type (not the function itself) and gets full autocompletion, type checking, and inference. If you change a field name on the server, the client breaks at compile time, not at runtime.

Here is what that looks like in practice:

server/routers/user.ts
// Server: define a procedure
export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const user = await db.user.findUnique({ where: { id: input.id } });
      return user; // Return type is inferred
    }),
});
app/components/UserProfile.tsx
// Client: types flow automatically
const { data: user } = trpc.user.getById.useQuery({ id: '123' });
// TypeScript knows: user is { id: string; name: string; email: string } | null
// Passing { id: 123 } would be a compile error (number instead of string)

The type travels from your database schema through Prisma, into tRPC, and out to your React component. No manual type definitions anywhere in between. This is what "end-to-end type safety" means in practice.

What tRPC is not

tRPC is not a replacement for REST or GraphQL in every scenario. It requires both client and server to be TypeScript. It does not generate API documentation for third-party consumers. It does not work across language boundaries. If you are building a public API that other teams or mobile apps consume, REST with OpenAPI or GraphQL is a better choice. tRPC is designed for the common case: a TypeScript frontend talking to a TypeScript backend within the same codebase.

tRPC vs REST vs GraphQL

Choosing between these three depends on your constraints. Here is an honest comparison.

REST

GraphQL

tRPC

Quick decision. If your frontend and backend are both TypeScript in the same repo, start with tRPC. If you need to serve non-TypeScript clients or expose a public API, use REST with OpenAPI. If you have a complex data graph with many different client needs, consider GraphQL.

Setting Up tRPC with Next.js App Router

This setup uses Next.js 14+ with the App Router, tRPC v11, and Zod for input validation. Start by installing the required packages.

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

The project structure follows a pattern that keeps tRPC configuration separate from your application logic.

Project structure
src/
  server/
    trpc.ts           # tRPC initialization + context
    routers/
      _app.ts         # Root router (merges all sub-routers)
      user.ts         # User procedures
      post.ts         # Post procedures
  app/
    api/trpc/[trpc]/
      route.ts        # Next.js API route handler
    _trpc/
      client.ts       # tRPC client for Client Components
      server.ts       # tRPC caller for Server Components
    providers.tsx     # React Query + tRPC provider

Step 1: Initialize tRPC on the server

Create the tRPC instance and define your context. The context is an object available in every procedure, typically containing the database client, session information, and any shared utilities.

src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';

export const createTRPCContext = async (opts: FetchCreateContextFnOptions) => {
  const session = await getSession(opts.req);
  return {
    db,
    session,
  };
};

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

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

The superjson transformer handles Date objects, Map, Set, and other types that JSON.stringify cannot serialize. The error formatter exposes Zod validation errors in a structured format so the client can display field-level messages.

Step 2: Create the API route 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,
  });

export { handler as GET, handler as POST };

This single route handler catches all tRPC requests. Next.js dynamic routes with [trpc] forward the procedure path to tRPC's internal router.

Creating Your First Router and Procedures

tRPC organizes your API into routers. Each router groups related procedures. Procedures come in three types:

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

export const postRouter = router({
  // Query: fetch a list of posts
  list: publicProcedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(10),
        cursor: z.string().nullish(), // for 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' },
      });

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

      return { posts, nextCursor };
    }),

  // Query: fetch a single post by ID
  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({ where: { id: input.id } });
      if (!post) throw new TRPCError({ code: 'NOT_FOUND' });
      return post;
    }),

  // Mutation: create a new post
  create: publicProcedure
    .input(
      z.object({
        title: z.string().min(1).max(200),
        content: z.string().min(1),
        published: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      return ctx.db.post.create({ data: input });
    }),
});

Merge routers into a single root router that represents your entire API.

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

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

// Export type for the client
export type AppRouter = typeof appRouter;

The AppRouter type export is the bridge between server and client. The client imports this type (not the runtime code) and uses it to infer all procedure types.

Input Validation with Zod

Every tRPC procedure can define an .input() validator using Zod. When a client sends invalid data, tRPC returns a BAD_REQUEST error with structured field-level details before your procedure logic ever runs.

Zod schema examples
import { z } from 'zod';

// String with constraints
const createUserInput = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'editor']),
  age: z.number().int().positive().optional(),
  tags: z.array(z.string()).max(10).default([]),
});

// Reuse schemas across procedures
const paginationInput = z.object({
  page: z.number().int().positive().default(1),
  perPage: z.number().int().min(1).max(100).default(20),
});

// Compose with .merge() or .extend()
const searchInput = paginationInput.extend({
  query: z.string().min(1),
  sortBy: z.enum(['relevance', 'date', 'title']).default('relevance'),
});

Zod schemas serve double duty: they validate the input at runtime and infer the TypeScript type at compile time. When you write .input(z.object({ id: z.string() })), both the runtime validator and the TypeScript type { id: string } come from the same source. No possibility of drift.

To validate and format your Zod schemas or convert JSON responses into TypeScript interfaces, the JSON to TypeScript Converter generates accurate type definitions from any JSON payload.

Avoid .passthrough() in production

Zod strips unknown keys by default. Using .passthrough() or .strict() changes this behavior. In tRPC, the default stripping behavior is what you want. It prevents clients from sending unexpected fields that could cause security issues or database errors.

React Query Integration

tRPC's React integration is built on top of @tanstack/react-query. Every useQuery and useMutation call is type-safe with zero manual generics.

Setting up the client

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 } from '@trpc/client';
import { useState } from 'react';
import { trpc } from './_trpc/client';
import superjson from 'superjson';

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 5 * 60 * 1000, // 5 minutes
        refetchOnWindowFocus: false,
      },
    },
  }));

  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          transformer: superjson,
        }),
      ],
    })
  );

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

Using queries

app/posts/page.tsx (Client Component)
'use client';

import { trpc } from '@/app/_trpc/client';

export default function PostsPage() {
  // TypeScript infers the return type from the server procedure
  const { data, isLoading, error } = trpc.post.list.useQuery({
    limit: 20,
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data?.posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Using mutations

'use client';

import { trpc } from '@/app/_trpc/client';

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

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

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    createPost.mutate({
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit" disabled={createPost.isPending}>
        {createPost.isPending ? 'Creating...' : 'Create Post'}
      </button>
      {createPost.error && <p>{createPost.error.message}</p>}
    </form>
  );
}

Server-side calling (Server Components)

For Server Components, you bypass the HTTP layer entirely and call procedures directly.

src/app/_trpc/server.ts
import 'server-only';

import { createCallerFactory } from '@/server/trpc';
import { appRouter } from '@/server/routers/_app';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { headers } from 'next/headers';

const createCaller = createCallerFactory(appRouter);

export async function createServerCaller() {
  const session = await getSession();
  return createCaller({ db, session });
}
app/posts/[id]/page.tsx (Server Component)
import { createServerCaller } from '@/app/_trpc/server';

export default async function PostPage({ params }: { params: { id: string } }) {
  const trpc = await createServerCaller();
  const post = await trpc.post.byId({ id: params.id });

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

Authentication and Middleware

tRPC middleware runs before a procedure executes. The most common use case is authentication: verifying that the request has a valid session before running the procedure logic.

src/server/trpc.ts (add to existing file)
const isAuthenticated = t.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: {
      // Override the context with a guaranteed non-null user
      session: ctx.session,
      user: ctx.session.user,
    },
  });
});

// Create a procedure that requires authentication
export const protectedProcedure = t.procedure.use(isAuthenticated);

Now any procedure built with protectedProcedure instead of publicProcedure automatically has ctx.user available and typed as non-null.

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

export const postRouter = router({
  // Anyone can read posts
  list: publicProcedure.query(/* ... */),

  // Only authenticated users can create posts
  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      content: z.string().min(1),
    }))
    .mutation(async ({ ctx, input }) => {
      // ctx.user is guaranteed to exist (TypeScript knows this)
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),
});

Role-based middleware

You can chain middleware for more granular authorization.

const isAdmin = t.middleware(async ({ ctx, next }) => {
  if (ctx.session?.user?.role !== 'admin') {
    throw new TRPCError({
      code: 'FORBIDDEN',
      message: 'Admin access required',
    });
  }
  return next({ ctx });
});

export const adminProcedure = t.procedure
  .use(isAuthenticated)
  .use(isAdmin);

Logging middleware

const logger = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;

  if (result.ok) {
    console.log(`[tRPC] ${type} ${path} - ${duration}ms`);
  } else {
    console.error(`[tRPC] ${type} ${path} - ${duration}ms - ERROR`);
  }

  return result;
});

For testing your API responses during development, the API Tester lets you send requests and inspect responses directly in the browser. And the JSON Formatter makes tRPC's JSON responses readable when debugging.

Error Handling Patterns

tRPC provides a TRPCError class with error codes that map to HTTP status codes. Throwing these errors from any procedure or middleware returns a structured error to the client.

Server-side error throwing
import { TRPCError } from '@trpc/server';

export const postRouter = router({
  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
      });

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

      if (post.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You can only delete your own posts',
        });
      }

      await ctx.db.post.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

Error codes reference

Client-side error handling

'use client';

import { TRPCClientError } from '@trpc/client';

function DeleteButton({ postId }: { postId: string }) {
  const deletePost = trpc.post.delete.useMutation({
    onError: (error) => {
      if (error instanceof TRPCClientError) {
        switch (error.data?.code) {
          case 'NOT_FOUND':
            alert('This post has already been deleted.');
            break;
          case 'FORBIDDEN':
            alert('You do not have permission to delete this post.');
            break;
          default:
            alert(`Error: ${error.message}`);
        }
      }
    },
    onSuccess: () => {
      // Redirect or update UI
    },
  });

  return (
    <button onClick={() => deletePost.mutate({ id: postId })}>
      Delete
    </button>
  );
}

Handling Zod validation errors on the client

When a Zod validation fails, tRPC includes the structured error in the response. You can use this to display field-level error messages.

const createPost = trpc.post.create.useMutation({
  onError: (error) => {
    // Access Zod field errors from the error formatter we set up earlier
    const zodErrors = error.data?.zodError?.fieldErrors;
    if (zodErrors) {
      // zodErrors is { title?: string[], content?: string[] }
      Object.entries(zodErrors).forEach(([field, messages]) => {
        console.log(`${field}: ${messages?.join(', ')}`);
      });
    }
  },
});

Testing tRPC Procedures

One of tRPC's best features is testability. Procedures are regular async functions. You test them by creating a caller with a mock context.

__tests__/post.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createCallerFactory } from '@/server/trpc';
import { appRouter } from '@/server/routers/_app';
import { db } from '@/lib/db'; // Your test database

const createCaller = createCallerFactory(appRouter);

describe('post router', () => {
  let caller: ReturnType<typeof createCaller>;

  beforeEach(() => {
    // Create a caller with a mock context
    caller = createCaller({
      db,
      session: {
        user: { id: 'test-user-1', name: 'Test', role: 'user' },
        expires: new Date(Date.now() + 86400000).toISOString(),
      },
    });
  });

  it('creates a post', async () => {
    const post = await caller.post.create({
      title: 'Test Post',
      content: 'This is a test post.',
    });

    expect(post.title).toBe('Test Post');
    expect(post.authorId).toBe('test-user-1');
  });

  it('returns NOT_FOUND for missing post', async () => {
    await expect(
      caller.post.byId({ id: 'nonexistent-id' })
    ).rejects.toThrow('NOT_FOUND');
  });

  it('prevents deleting another user\'s post', async () => {
    // Create a post as a different user
    const otherCaller = createCaller({
      db,
      session: {
        user: { id: 'other-user', name: 'Other', role: 'user' },
        expires: new Date(Date.now() + 86400000).toISOString(),
      },
    });

    const post = await otherCaller.post.create({
      title: 'Their Post',
      content: 'Content',
    });

    // Try to delete as test-user-1
    await expect(
      caller.post.delete({ id: post.id })
    ).rejects.toThrow('FORBIDDEN');
  });
});

This pattern tests your actual business logic, including middleware and validation, without starting an HTTP server. The test runs in milliseconds.

Testing tip

Use a separate test database (SQLite in-memory or a Dockerized Postgres) and reset it between tests. Prisma's $transaction with rollback is another pattern for fast, isolated tests.

Production Tips and Performance

Request batching

tRPC batches multiple procedure calls from the same render cycle into a single HTTP request by default. If a page calls trpc.user.me.useQuery() and trpc.post.list.useQuery(), both resolve in one network round-trip.

This is enabled automatically with httpBatchLink. If you need to disable it for specific procedures (like file uploads), use httpLink for those calls.

Prefetching and SSR

For Server-Side Rendering, prefetch data on the server and pass it to the client to avoid a loading flash.

// In a Server Component
import { createServerCaller } from '@/app/_trpc/server';
import { HydrateClient } from '@/app/_trpc/client';

export default async function PostsPage() {
  const trpc = await createServerCaller();

  // Prefetch on the server
  const posts = await trpc.post.list({ limit: 20 });

  // Pass to client as props or use React Server Components directly
  return <PostsList initialData={posts} />;
}

Response caching

Add HTTP cache headers in your procedures for CDN caching.

export const postRouter = router({
  list: publicProcedure
    .input(paginationInput)
    .query(async ({ ctx, input }) => {
      // Set cache headers (only works with HTTP transport)
      ctx.resHeaders?.set(
        'Cache-Control',
        'public, s-maxage=60, stale-while-revalidate=300'
      );

      return ctx.db.post.findMany({
        take: input.perPage,
        skip: (input.page - 1) * input.perPage,
      });
    }),
});

Monitoring and observability

Add a global middleware that logs slow queries and errors to your monitoring service.

const observability = t.middleware(async ({ path, type, next }) => {
  const start = performance.now();
  const result = await next();
  const duration = performance.now() - start;

  // Log slow procedures (over 1 second)
  if (duration > 1000) {
    console.warn(`[SLOW] ${type} ${path} took ${duration.toFixed(0)}ms`);
  }

  // Send to your monitoring (Sentry, Datadog, etc.)
  if (!result.ok) {
    captureException(result.error, {
      tags: { trpc_path: path, trpc_type: type },
    });
  }

  return result;
});

Bundle size

tRPC adds minimal client-side JavaScript. The @trpc/client package is roughly 5KB gzipped. The main dependency is @tanstack/react-query at approximately 13KB gzipped. Together they are smaller than most GraphQL clients.

Debug your API payloads. The JSON Formatter makes complex tRPC responses readable, and the JSON Path Finder helps you navigate deeply nested response objects.

Environment variables

Keep your tRPC endpoint URL configurable across environments. Use the Env File Editor to manage environment variables across development, staging, and production without manual editing mistakes.

// In your tRPC client setup
httpBatchLink({
  url: `${process.env.NEXT_PUBLIC_APP_URL}/api/trpc`,
  transformer: superjson,
})

Related Developer Tools

Free browser-based tools to complement your tRPC and TypeScript API development.


Frequently Asked Questions

tRPC is a TypeScript-first framework for building APIs where the server and client share types directly through TypeScript inference, without code generation or schema files. Unlike REST, where you manually define request and response types on both sides, tRPC infers them automatically from your server code. Unlike GraphQL, which requires a schema definition language, a code generator, and a runtime query parser, tRPC uses plain TypeScript functions. The tradeoff is that tRPC only works when both your client and server are written in TypeScript within the same monorepo or shared package setup. If you need to serve mobile apps, third-party consumers, or non-TypeScript clients, REST with OpenAPI or GraphQL is a better fit.

Yes. tRPC works with Next.js App Router through two patterns. For Server Components, you create a server-side caller that invokes tRPC procedures directly without an HTTP round-trip. For Client Components, you use the standard React Query integration with a tRPC client that points to a Next.js API route handler. The API route handler is set up at app/api/trpc/[trpc]/route.ts using the fetchRequestHandler from @trpc/server/adapters/fetch. This setup gives you type-safe data fetching in both server and client contexts within the same Next.js application.

No, tRPC builds on top of React Query. When you use @trpc/react-query, every tRPC procedure call is backed by React Query under the hood. You get all of React Query's features including caching, background refetching, optimistic updates, infinite queries, and stale-while-revalidate behavior. tRPC adds automatic type inference on top of that so your query keys, input parameters, and return types are all type-safe without any manual generics. You can also access the underlying React Query client directly for advanced patterns like cache manipulation or prefetching.

tRPC provides a TRPCError class for throwing typed errors from procedures. Each error includes a code (like NOT_FOUND, UNAUTHORIZED, BAD_REQUEST, or INTERNAL_SERVER_ERROR) that maps to an HTTP status code. On the client, failed procedures throw a TRPCClientError that includes the error code, message, and any additional data you attached. For Zod validation errors, tRPC automatically returns a BAD_REQUEST with structured field-level error details. You can also set up a global error formatter on the server to customize the error shape, add logging, or strip sensitive information before errors reach the client.

Yes. tRPC is production-ready and has been used in production by companies including Cal.com, Ping.gg, and many startups built on the T3 Stack (Next.js + tRPC + Prisma + Tailwind). The library has over 35,000 GitHub stars and a stable v11 release. It is maintained by a dedicated team and has a well-documented migration path between major versions. tRPC is particularly popular in full-stack TypeScript applications where both the frontend and backend share a single repository, which is the most common architecture for Next.js projects.

NT

Christian Bucher

We build free developer tools including TypeScript playgrounds, JSON formatters, API testers, and many more. All browser-based, no signup required.

269 Developer Tools, One Place

Browse 269 indexed tool pages with no QTool account required, and inspect the source on GitHub.

Open Source — Free Forever Try Free Tools

Related Articles

Built by Miguel

Need a custom tool or website?

From . Delivered in 24-48h. You own the code.

View Services →