In the previous tutorial, we learned about Zod for runtime validation. Now let’s learn about tRPC — a library that gives you end-to-end type safety from server to client without writing any API schema.

By the end of this tutorial, you will know how to set up tRPC, create procedures, validate input with Zod, use it with Next.js, and understand when tRPC is the right choice.

What is tRPC?

tRPC lets you call server functions directly from the client with full type safety. No REST endpoints. No GraphQL schemas. No code generation.

When you change a function on the server, the client gets updated types instantly. If you break the contract, TypeScript shows an error at compile time.

Server: Define a function → Client: Call it with types → No API layer to maintain

How tRPC Works

  1. You define procedures (functions) on the server
  2. You export the router type (just the type, not the actual code)
  3. The client imports the type and calls procedures like regular functions
  4. TypeScript ensures the input and output types match

No manually written REST endpoints, no shared schemas to maintain, no JSON parsing in your client code. tRPC manages the HTTP transport layer automatically.

Setting Up tRPC with Next.js

Install Dependencies

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

Create the tRPC Server

// src/server/trpc.ts
import { initTRPC } from "@trpc/server";

const t = initTRPC.create();

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

Define Your Router

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

export const userRouter = router({
  // Query — read data
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      // input is typed: { id: string }
      const user = await findUser(input.id);
      return user; // Return type is inferred
    }),

  // Query — list data
  list: publicProcedure
    .query(async () => {
      return await getAllUsers();
    }),

  // Mutation — write data
  create: publicProcedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      // input is typed: { name: string; email: string }
      return await createUser(input);
    }),

  // Mutation — delete data
  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      await deleteUser(input.id);
      return { success: true };
    }),
});

Create the App Router

// src/server/routers/index.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { noteRouter } from "./note";

export const appRouter = router({
  user: userRouter,
  note: noteRouter,
});

// Export the type — this is what the client imports
export type AppRouter = typeof appRouter;

Set Up the Next.js API Handler

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

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

export { handler as GET, handler as POST };

Client Setup with React Query

Create the tRPC Client

// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers";

export const trpc = createTRPCReact<AppRouter>();

Provider Setup

"use client";

// src/app/providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { useState } from "react";
import { trpc } from "@/lib/trpc";

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: "/api/trpc",
        }),
      ],
    })
  );

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

Using tRPC in Components

Queries — Reading Data

"use client";

import { trpc } from "@/lib/trpc";

export function UserList() {
  const { data: users, isLoading, error } = trpc.user.list.useQuery();

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

  return (
    <ul>
      {users?.map(user => (
        <li key={user.id}>{user.name} ({user.email})</li>
      ))}
    </ul>
  );
}

TypeScript knows the type of users based on what the server returns. You get full autocomplete.

Queries with Input

export function UserProfile({ userId }: { userId: string }) {
  const { data: user } = trpc.user.getById.useQuery({ id: userId });
  // TypeScript knows: id must be a string

  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Mutations — Writing Data

"use client";

import { useState } from "react";
import { trpc } from "@/lib/trpc";

export function CreateUserForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");

  const utils = trpc.useUtils();

  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      // Refresh the user list after creating
      utils.user.list.invalidate();
      setName("");
      setEmail("");
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    createUser.mutate({ name, email });
    // TypeScript enforces: name must be string, email must be string
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} placeholder="Name" />
      <input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" />
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? "Creating..." : "Create User"}
      </button>
      {createUser.error && <p>Error: {createUser.error.message}</p>}
    </form>
  );
}

Input Validation with Zod

tRPC uses Zod schemas for input validation. Invalid input is rejected before your function runs:

const noteRouter = router({
  create: publicProcedure
    .input(z.object({
      title: z.string().min(1, "Title is required").max(200),
      content: z.string().min(1, "Content is required"),
      tags: z.array(z.string()).max(5).optional(),
    }))
    .mutation(async ({ input }) => {
      // input is guaranteed to be valid here
      return await createNote(input);
    }),
});

If the client sends invalid data, tRPC returns a 400 error with Zod’s error messages. The server function never runs.

Middleware

Middleware runs before your procedures. Use it for authentication, logging, or rate limiting:

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

const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      user: ctx.session.user, // user is now available in procedures
    },
  });
});

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

Use protectedProcedure instead of publicProcedure for authenticated routes:

const noteRouter = router({
  myNotes: protectedProcedure
    .query(async ({ ctx }) => {
      // ctx.user is typed and guaranteed to exist
      return await getNotesByUser(ctx.user.id);
    }),
});

Error Handling

tRPC uses typed errors:

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

const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const user = await findUser(input.id);
      if (!user) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: `User ${input.id} not found`,
        });
      }
      return user;
    }),
});

Available error codes: UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST, INTERNAL_SERVER_ERROR, TIMEOUT, CONFLICT, and more.

On the client, errors are typed:

const { error } = trpc.user.getById.useQuery({ id: "123" });

if (error) {
  console.log(error.message); // "User 123 not found"
  console.log(error.data?.code); // "NOT_FOUND"
}

tRPC vs REST vs GraphQL

FeaturetRPCRESTGraphQL
Type safetyEnd-to-endManual (OpenAPI)With codegen
Schema/contractNone neededOpenAPI specSDL
Code generationNoneOptionalRequired
Learning curveLowLowMedium
Client libraryRequiredAny HTTP clientApollo, urql
Non-TS clientsNoYesYes
Public APIsNoYesYes

When to Use tRPC

  • Full-stack TypeScript projects (both server and client)
  • Internal APIs that only your frontend consumes
  • Projects where you want maximum developer experience
  • Next.js, Remix, or similar full-stack frameworks

When NOT to Use tRPC

  • Public APIs consumed by non-TypeScript clients
  • Mobile apps with native code (Swift, Kotlin)
  • Microservices with different languages
  • When you need a formal API specification

What’s Next?

In the next tutorial, we will learn about advanced TypeScript patterns — discriminated unions, branded types, builder pattern, and type-safe event systems.