DevToolBoxGRATUIT
Blog

Guide Complet tRPC : APIs Type-Safe de Bout en Bout pour TypeScript

22 min readpar DevToolBox Team

TL;DR

tRPC lets you build fully type-safe APIs without writing API schemas, code generation, or REST/GraphQL boilerplate. You define server procedures and call them from your client with full autocompletion and type checking. Changes to your API are caught by TypeScript at compile time, not by your users in production. It integrates natively with Zod for validation and React Query for data fetching.

Key Takeaways

  • tRPC provides end-to-end type safety from server to client without schemas or code generation
  • Built on top of React Query, giving you caching, deduplication, and background refetching for free
  • Zod integration makes input validation type-safe and declarative
  • Middleware and context enable authentication, authorization, and request-scoped data
  • Works with Next.js App Router, Express, Fastify, and other frameworks
  • Subscriptions via WebSockets for real-time features

What Is tRPC?

tRPC (TypeScript Remote Procedure Call) is a library that enables you to build type-safe APIs for TypeScript applications. Unlike REST or GraphQL, tRPC requires no API schema definition, no code generation step, and no runtime overhead for type checking. Your server-side procedure definitions become the single source of truth, and TypeScript ensures your client calls match exactly.

With tRPC, when you rename a field on the server, TypeScript immediately flags every client usage that needs updating. When you add a required input parameter, the client code fails to compile until it provides it. This eliminates an entire class of bugs that traditionally plague full-stack development.

// Traditional REST workflow:
// 1. Define REST routes on server
// 2. Write fetch calls on client
// 3. Manually type the response
// 4. Hope they stay in sync

// tRPC workflow:
// 1. Define a procedure on the server
const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => {
      return db.user.findUnique({ where: { id: input.id } });
    }),
});

// 2. Call it from the client — fully typed!
const { data } = trpc.getUser.useQuery({ id: "123" });
// data is automatically typed as User | null
// TypeScript error if you pass wrong input shape

Setting Up tRPC with Next.js

The most common tRPC setup uses Next.js with the App Router. Here is how to install and configure everything from scratch.

Installation

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

Initialize the tRPC Server

// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";

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

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;

Create the API Route Handler (Next.js App Router)

// 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 };

Set Up the Client Provider

// src/lib/trpc.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 { trpc } from "@/lib/trpc";
import superjson from "superjson";
import { useState } from "react";

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: { staleTime: 5 * 60 * 1000 },
    },
  }));
  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>
  );
}

Setting Up tRPC with Express

tRPC also works as a standalone Express middleware, which is useful if you are not using Next.js.

// server.ts
import express from "express";
import * as trpcExpress from "@trpc/server/adapters/express";
import { appRouter } from "./routers/_app";
import { createTRPCContext } from "./trpc";

const app = express();

app.use(
  "/api/trpc",
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext: ({ req, res }) => ({
      db: prisma,
      session: req.session,
    }),
  })
);

app.listen(3000, () => {
  console.log("Server running on port 3000");
});

Routers and Procedures

Routers organize your API into logical groups. Procedures are the individual endpoints within a router. There are three types of procedures: queries (read data), mutations (write data), and subscriptions (real-time streams).

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

export const userRouter = router({
  // Query: fetch data (GET-like)
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.db.user.findUnique({
        where: { id: input.id },
      });
    }),

  // Query: list with pagination
  list: publicProcedure
    .input(z.object({
      page: z.number().int().min(1).default(1),
      limit: z.number().int().min(1).max(100).default(20),
    }))
    .query(async ({ ctx, input }) => {
      const { page, limit } = input;
      const [users, total] = await Promise.all([
        ctx.db.user.findMany({
          skip: (page - 1) * limit,
          take: limit,
        }),
        ctx.db.user.count(),
      ]);
      return { users, total, pages: Math.ceil(total / limit) };
    }),

  // Mutation: write data (POST/PUT/DELETE-like)
  update: protectedProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().min(1).max(100).optional(),
      email: z.string().email().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.db.user.update({
        where: { id: input.id },
        data: input,
      });
    }),
});

Nested Routers

You can nest routers to create a hierarchical API structure. This keeps your code organized as your API grows.

// src/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,
});

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

// Client usage:
// trpc.user.getById.useQuery({ id: "123" })
// trpc.post.list.useQuery({ page: 1 })
// trpc.comment.create.useMutation()

Input Validation with Zod

tRPC integrates with Zod for input validation. When you define an input schema, tRPC validates incoming data at runtime and infers the TypeScript type for your procedure handler automatically.

import { z } from "zod";
import { router, publicProcedure } from "../trpc";

export const productRouter = router({
  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(1, "Name is required").max(200),
        price: z.number().positive("Price must be positive"),
        category: z.enum(["electronics", "clothing", "food"]),
        tags: z.array(z.string()).max(10).default([]),
        metadata: z.record(z.string(), z.unknown()).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // input is fully typed:
      // {
      //   name: string;
      //   price: number;
      //   category: "electronics" | "clothing" | "food";
      //   tags: string[];
      //   metadata?: Record<string, unknown>;
      // }
      return ctx.db.product.create({ data: input });
    }),
});

Complex Input Schemas

// Reusable input schemas
const PaginationInput = z.object({
  cursor: z.string().nullish(),
  limit: z.number().int().min(1).max(100).default(20),
});

const DateRangeInput = z.object({
  from: z.date(),
  to: z.date(),
}).refine(
  (data) => data.from <= data.to,
  { message: "Start date must be before end date" }
);

const SearchInput = z.object({
  query: z.string().min(1).max(500),
  filters: z.object({
    category: z.enum(["all", "posts", "users"]).default("all"),
    dateRange: DateRangeInput.optional(),
    sortBy: z.enum(["relevance", "date", "popularity"]).default("relevance"),
  }).default({}),
  pagination: PaginationInput.default({}),
});

export const searchRouter = router({
  search: publicProcedure
    .input(SearchInput)
    .query(async ({ ctx, input }) => {
      // All fields are validated and typed
      const { query, filters, pagination } = input;
      return ctx.db.search(query, filters, pagination);
    }),
});

Queries and Mutations

Queries fetch data and are called with useQuery on the client. Mutations modify data and are called with useMutation. Both are fully type-safe.

Calling from the Client

// Using queries in React components
function UserProfile({ userId }: { userId: string }) {
  // Fully typed query — data type is inferred from server
  const { data: user, isLoading, error } = trpc.user.getById.useQuery(
    { id: userId },
    { enabled: !!userId }
  );

  // Fully typed mutation
  const updateUser = trpc.user.update.useMutation({
    onSuccess: () => {
      // Invalidate and refetch the query after mutation
      utils.user.getById.invalidate({ id: userId });
    },
    onError: (err) => {
      // err.data?.zodError has field-level errors
      console.error("Update failed:", err.message);
    },
  });

  const utils = trpc.useUtils();

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

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      updateUser.mutate({
        id: userId,
        name: "New Name",  // TypeScript ensures valid fields
      });
    }}>
      <p>{user.name}</p>
      <p>{user.email}</p>
      <button type="submit" disabled={updateUser.isPending}>
        {updateUser.isPending ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

Middleware

Middleware lets you run shared logic before your procedures execute. Common uses include authentication checks, logging, rate limiting, and timing. Middleware can modify the context that gets passed to the procedure.

Authentication Middleware

// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in",
    });
  }
  // Add user to context for downstream procedures
  return next({
    ctx: {
      session: ctx.session,
      user: ctx.session.user,  // Now guaranteed to exist
    },
  });
});

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

// Logging middleware
const logger = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
  console.log(
    `[tRPC] \${type} \${path} - \${duration}ms - ` +
    `\${result.ok ? "OK" : "ERROR"}`
  );
  return result;
});

// Role-based middleware
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 = protectedProcedure.use(isAdmin);

Context

Context is data that is available to all procedures and middleware. It is created per-request and typically includes the database connection, the authenticated user, and other request-scoped state.

// src/server/context.ts
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";

export const createTRPCContext = async (opts: {
  headers: Headers;
}) => {
  const session = await getServerSession(authOptions);

  return {
    // Database client — shared across all procedures
    db: prisma,
    // Authenticated session (null if not logged in)
    session,
    // Request headers for IP, user-agent, etc.
    headers: opts.headers,
    // Request ID for tracing
    requestId: crypto.randomUUID(),
  };
};

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

// Procedures access context via ctx:
// .query(async ({ ctx }) => {
//   ctx.db       — Prisma client
//   ctx.session  — Auth session
//   ctx.headers  — Request headers
// })

Error Handling

tRPC provides a TRPCError class for throwing structured errors with HTTP status codes. These errors are serialized and sent to the client where they can be caught and handled.

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

export const postRouter = router({
  getById: 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",
          message: "Post not found",
        });
      }

      // Check permission
      if (post.isPrivate && post.authorId !== ctx.session?.user?.id) {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "You do not have permission to view this post",
        });
      }

      return post;
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      try {
        return await ctx.db.post.delete({
          where: { id: input.id, authorId: ctx.user.id },
        });
      } catch (e) {
        throw new TRPCError({
          code: "INTERNAL_SERVER_ERROR",
          message: "Failed to delete post",
          cause: e,
        });
      }
    }),
});

// Available error codes:
// PARSE_ERROR          400
// BAD_REQUEST          400
// UNAUTHORIZED         401
// FORBIDDEN            403
// NOT_FOUND            404
// METHOD_NOT_SUPPORTED 405
// TIMEOUT              408
// CONFLICT             409
// TOO_MANY_REQUESTS    429
// INTERNAL_SERVER_ERROR 500

Custom Error Formatting

// Custom error formatting in initTRPC
const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        // Include Zod validation errors
        zodError:
          error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    };
  },
});

// Client-side error handling
const mutation = trpc.user.update.useMutation({
  onError: (error) => {
    if (error.data?.zodError) {
      // Field-level validation errors
      const fieldErrors = error.data.zodError.fieldErrors;
      // { name: ["Too short"], email: ["Invalid email"] }
    } else {
      // General server error
      toast.error(error.message);
    }
  },
});

React Query Integration

tRPC wraps React Query (TanStack Query) to provide type-safe data fetching hooks. You get all React Query features like caching, background refetching, optimistic updates, and infinite queries with full type inference.

Optimistic Updates

function TodoList() {
  const utils = trpc.useUtils();
  const todos = trpc.todo.list.useQuery();

  const toggleTodo = trpc.todo.toggle.useMutation({
    // Optimistically update the UI before server responds
    onMutate: async (input) => {
      // Cancel any outgoing refetches
      await utils.todo.list.cancel();

      // Snapshot the previous value
      const previousTodos = utils.todo.list.getData();

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

      return { previousTodos };
    },
    // If mutation fails, roll back to snapshot
    onError: (err, input, context) => {
      utils.todo.list.setData(undefined, context?.previousTodos);
    },
    // Always refetch after error or success
    onSettled: () => {
      utils.todo.list.invalidate();
    },
  });

  return (
    <ul>
      {todos.data?.map((todo) => (
        <li key={todo.id} onClick={() => toggleTodo.mutate({ id: todo.id })}>
          {todo.completed ? "[x]" : "[ ]"} {todo.text}
        </li>
      ))}
    </ul>
  );
}

Infinite Queries

// Server: cursor-based pagination
export const feedRouter = router({
  infiniteFeed: publicProcedure
    .input(z.object({
      cursor: z.string().nullish(),
      limit: z.number().min(1).max(50).default(20),
    }))
    .query(async ({ ctx, input }) => {
      const items = await ctx.db.post.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 nextItem = items.pop();
        nextCursor = nextItem?.id;
      }
      return { items, nextCursor };
    }),
});

// Client: infinite scroll
function Feed() {
  const feed = trpc.feed.infiniteFeed.useInfiniteQuery(
    { limit: 20 },
    { getNextPageParam: (lastPage) => lastPage.nextCursor }
  );

  return (
    <div>
      {feed.data?.pages.map((page) =>
        page.items.map((item) => (
          <PostCard key={item.id} post={item} />
        ))
      )}
      <button
        onClick={() => feed.fetchNextPage()}
        disabled={!feed.hasNextPage || feed.isFetchingNextPage}
      >
        {feed.isFetchingNextPage ? "Loading..." : "Load more"}
      </button>
    </div>
  );
}

Subscriptions (Real-Time)

Subscriptions use WebSockets to push data from the server to the client in real time. They are useful for chat applications, live dashboards, notifications, and collaborative editing.

// Server: WebSocket subscription
import { observable } from "@trpc/server/observable";
import { EventEmitter } from "events";

const ee = new EventEmitter();

export const chatRouter = router({
  sendMessage: protectedProcedure
    .input(z.object({
      roomId: z.string(),
      text: z.string().min(1).max(1000),
    }))
    .mutation(async ({ ctx, input }) => {
      const message = {
        id: crypto.randomUUID(),
        text: input.text,
        roomId: input.roomId,
        userId: ctx.user.id,
        createdAt: new Date(),
      };
      await ctx.db.message.create({ data: message });
      ee.emit("newMessage", message);
      return message;
    }),

  onNewMessage: publicProcedure
    .input(z.object({ roomId: z.string() }))
    .subscription(({ input }) => {
      return observable((emit) => {
        const handler = (message: Message) => {
          if (message.roomId === input.roomId) {
            emit.next(message);
          }
        };
        ee.on("newMessage", handler);
        return () => ee.off("newMessage", handler);
      });
    }),
});

// Client: subscribe to messages
function ChatRoom({ roomId }: { roomId: string }) {
  trpc.chat.onNewMessage.useSubscription(
    { roomId },
    {
      onData: (message) => {
        // Append new message to local state
        setMessages((prev) => [...prev, message]);
      },
      onError: (err) => {
        console.error("Subscription error:", err);
      },
    }
  );
}

Testing tRPC Procedures

You can test tRPC procedures directly by calling the router without an HTTP layer. This makes unit tests fast and simple.

// __tests__/user.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { appRouter } from "@/server/routers/_app";
import { createCallerFactory } from "@/server/trpc";
import { prisma } from "@/lib/__mocks__/prisma";

const createCaller = createCallerFactory(appRouter);

describe("user router", () => {
  it("should return a user by ID", async () => {
    const caller = createCaller({
      db: prisma,
      session: null,
      headers: new Headers(),
    });

    prisma.user.findUnique.mockResolvedValue({
      id: "1",
      name: "Alice",
      email: "alice@example.com",
    });

    const user = await caller.user.getById({ id: "1" });
    expect(user).toEqual({
      id: "1",
      name: "Alice",
      email: "alice@example.com",
    });
  });

  it("should throw NOT_FOUND for missing user", async () => {
    const caller = createCaller({
      db: prisma,
      session: null,
      headers: new Headers(),
    });

    prisma.user.findUnique.mockResolvedValue(null);

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

  it("should reject invalid input", async () => {
    const caller = createCaller({
      db: prisma,
      session: { user: { id: "1", role: "user" } },
      headers: new Headers(),
    });

    await expect(
      caller.user.update({ id: "1", email: "not-an-email" })
    ).rejects.toThrow();
  });

  it("should require auth for protected routes", async () => {
    const caller = createCaller({
      db: prisma,
      session: null,  // Not logged in
      headers: new Headers(),
    });

    await expect(
      caller.user.update({ id: "1", name: "Hacker" })
    ).rejects.toThrow("UNAUTHORIZED");
  });
});

Performance Tips

tRPC is already fast because it skips schema parsing and code generation, but there are additional optimizations you can apply.

  • Use HTTP batching: tRPC batches multiple requests into a single HTTP call by default, reducing network overhead
  • Enable transformer: Use superjson to serialize Dates, Maps, Sets, and other non-JSON types without manual conversion
  • Leverage React Query caching: Configure staleTime and gcTime to avoid unnecessary refetches
  • Use server-side calls: In Next.js Server Components, call procedures directly without HTTP overhead using createCallerFactory
  • Split routers: Use dynamic imports for large routers to reduce bundle size
  • Connection pooling: Reuse database connections in context creation instead of opening new ones per request
// Server-side calls in Next.js Server Components
// (no HTTP overhead — direct function call)
import { createCallerFactory } from "@/server/trpc";
import { appRouter } from "@/server/routers/_app";

const createCaller = createCallerFactory(appRouter);

export default async function UserPage({
  params,
}: {
  params: { id: string };
}) {
  const caller = createCaller(await createTRPCContext({
    headers: headers(),
  }));

  // Direct procedure call — no HTTP request
  const user = await caller.user.getById({ id: params.id });

  return <UserProfileView user={user} />;
}

Frequently Asked Questions

How does tRPC differ from REST and GraphQL?

REST requires manually maintaining API contracts and has no built-in type safety. GraphQL provides a schema but requires code generation to get TypeScript types. tRPC skips both the schema definition and code generation by using TypeScript inference directly. Your server code IS your API contract. The trade-off is that tRPC only works with TypeScript clients, while REST and GraphQL are language-agnostic.

Can I use tRPC with a non-TypeScript client (mobile app, Python)?

tRPC is designed for TypeScript-to-TypeScript communication. For non-TypeScript clients, you can expose a REST or GraphQL API alongside tRPC, or use trpc-openapi to generate an OpenAPI spec from your tRPC router that any HTTP client can consume.

Does tRPC replace React Query?

No, tRPC is built on top of React Query (TanStack Query). It uses React Query under the hood for caching, background refetching, optimistic updates, and all other React Query features. tRPC adds type-safe procedure calls on top of that foundation.

How do I handle file uploads with tRPC?

tRPC does not natively handle multipart form data for file uploads. You can handle file uploads through a separate REST endpoint or use a presigned URL approach where tRPC returns a signed upload URL and the client uploads directly to cloud storage (S3, R2, etc).

Can I use tRPC without Next.js?

Yes. tRPC has adapters for Express, Fastify, standalone Node.js HTTP server, AWS Lambda, Cloudflare Workers, and more. Next.js is the most common setup but not required. You can use any Node.js server framework.

Is tRPC production-ready?

Yes. tRPC v11 is stable and used in production by companies of all sizes. It has been battle-tested since 2021 and is maintained actively by the core team and community contributors.

How do I version my tRPC API?

Since tRPC is typically used within a single TypeScript monorepo, versioning is handled by your deployment process. If you need versioning for external consumers, use trpc-openapi to expose versioned REST endpoints alongside your tRPC procedures.

Does tRPC support server-side rendering?

Yes. In Next.js, you can prefetch tRPC queries on the server using the createCaller API or React Server Components. The data is serialized to the client where React Query hydrates its cache, avoiding duplicate fetches on mount.

𝕏 Twitterin LinkedIn
Cet article vous a-t-il aidé ?

Restez informé

Recevez des astuces dev et les nouveaux outils chaque semaine.

Pas de spam. Désabonnez-vous à tout moment.

Essayez ces outils associés

{ }JSON FormatterJSON Validator

Articles connexes

Guide Complet Hono : Framework Web Ultra-Rapide pour l'Edge

Maîtrisez Hono avec le routage, middleware, validation Zod, JWT, CORS et support multi-runtime.

Next.js App Router : Guide de migration complet 2026

Maitrisez le Next.js App Router avec ce guide complet. Server Components, fetching de donnees, layouts, streaming, Server Actions et migration depuis Pages Router.

Guide complet des generiques TypeScript 2026 : des bases aux patterns avances

Maitrisez les generiques TypeScript : parametres de type, contraintes, types conditionnels, types mappes, types utilitaires et patterns concrets.