Przejdź do treści
24 min czytania

tRPC: End-to-end type safety bez zbędnych ceremonii

Jak tRPC eliminuje problem kontraktu API, współpracuje z Next.js App Router, obsługuje middleware auth, upload plików i subskrypcje. Realny setup tRPC od zera.

Udostępnij:X / TwitterLinkedIn

Znasz ten scenariusz. Zmieniasz nazwę pola w odpowiedzi API. Aktualizujesz typ na backendzie. Deployujesz. A potem frontend się wysypuje na produkcji, bo ktoś zapomniał zaktualizować wywołanie fetch w linii 247 w Dashboard.tsx. Pole jest teraz undefined, komponent renderuje się pusty, i twój error tracking rozświetla się o 2 w nocy.

To jest problem kontraktu API. To nie jest problem technologiczny. To problem koordynacyjny. I żadna ilość dokumentacji Swagger ani schematów GraphQL nie naprawi faktu, że typy twojego frontendu i backendu mogą się cicho rozsynchronizować.

tRPC naprawia to, odmawiając pozwolenia na rozsynchronizowanie. Nie ma pliku schematu. Nie ma kroku generowania kodu. Nie ma osobnego kontraktu do utrzymania. Piszesz funkcję TypeScript na serwerze, a klient zna jej dokładne typy wejścia i wyjścia w czasie kompilacji. Jeśli zmienisz nazwę pola, frontend się nie skompiluje, dopóki tego nie naprawisz.

Taka jest obietnica. Pokażę ci, jak to naprawdę działa, gdzie świeci, i gdzie absolutnie nie powinieneś tego używać.

Problem, precyzyjniej#

Spójrzmy, jak większość zespołów buduje API dzisiaj.

REST + OpenAPI: Piszesz swoje endpointy. Może dodajesz adnotacje Swagger. Może generujesz klienckie SDK ze specyfikacji OpenAPI. Ale specyfikacja to osobny artefakt. Może się zdezaktualizować. Krok generowania to kolejna rzecz w twoim pipeline CI, która może się zepsuć lub zostać zapomniana. A wygenerowane typy są często brzydkie — głęboko zagnieżdżone potwory paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"].

GraphQL: Lepsze bezpieczeństwo typów, ale ogromna ceremonia. Piszesz schemat w SDL. Piszesz resolvery. Generujesz typy ze schematu. Piszesz zapytania po stronie klienta. Generujesz typy z zapytań. To co najmniej dwa kroki generowania kodu, plik schematu i krok budowania, o którym wszyscy muszą pamiętać. Dla zespołu, który już kontroluje zarówno frontend, jak i backend, to dużo infrastruktury dla problemu, który ma prostsze rozwiązanie.

Ręczne wywołania fetch: Najpopularniejsze podejście i najniebezpieczniejsze. Piszesz fetch("/api/users"), rzutujesz wynik na User[] i masz nadzieję na najlepsze. Zero bezpieczeństwa w czasie kompilacji. Asercja typu to kłamstwo, które mówisz TypeScriptowi.

typescript
// The lie every frontend developer has told
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 hope this is right

tRPC przyjmuje całkowicie inne podejście. Zamiast opisywać API w osobnym formacie i generować typy, piszesz zwykłe funkcje TypeScript na serwerze i importujesz ich typy bezpośrednio do klienta. Żadnego kroku generowania. Żadnego pliku schematu. Żadnego dryfu.

Podstawowe koncepcje#

Zanim cokolwiek skonfigurujemy, zrozummy model mentalny.

Router#

Router tRPC to kolekcja procedur pogrupowanych razem. Pomyśl o nim jak o kontrolerze w MVC, tyle że to po prostu zwykły obiekt z wbudowaną inferencją typów.

typescript
import { initTRPC } from "@trpc/server";
 
const t = initTRPC.create();
 
export const appRouter = t.router({
  user: t.router({
    list: t.procedure.query(/* ... */),
    byId: t.procedure.input(/* ... */).query(/* ... */),
    create: t.procedure.input(/* ... */).mutation(/* ... */),
  }),
  post: t.router({
    list: t.procedure.query(/* ... */),
    publish: t.procedure.input(/* ... */).mutation(/* ... */),
  }),
});
 
export type AppRouter = typeof appRouter;

Ten eksport typu AppRouter to cała sztuczka magiczna. Klient importuje ten typ — nie kod runtime'owy, tylko typ — i dostaje pełne autouzupełnianie i sprawdzanie typów dla każdej procedury.

Procedura#

Procedura to pojedynczy endpoint. Są trzy rodzaje:

  • Query: Operacje odczytu. Mapuje się na semantykę HTTP GET. Cachowane przez TanStack Query.
  • Mutation: Operacje zapisu. Mapuje się na HTTP POST. Nie cachowane.
  • Subscription: Strumienie czasu rzeczywistego. Używa WebSocketów.

Context#

Context to dane przypisane do żądania, dostępne w każdej procedurze. Połączenia do bazy danych, zalogowany użytkownik, nagłówki żądania — wszystko, co wstawiłbyś do obiektu req w Express, trafia tutaj.

Middleware#

Middleware transformuje context lub kontroluje dostęp. Najpopularniejszy wzorzec to middleware auth, który sprawdza poprawność sesji i dodaje ctx.user.

Łańcuch inferencji typów#

To jest kluczowy model mentalny. Kiedy definiujesz procedurę tak:

typescript
t.procedure
  .input(z.object({ id: z.string() }))
  .query(({ input }) => {
    // input is typed as { id: string }
    return db.user.findUnique({ where: { id: input.id } });
  });

Typ zwracany przepływa aż do klienta. Jeśli db.user.findUnique zwraca User | null, hook useQuery klienta będzie miał data typowane jako User | null. Żadnego ręcznego typowania. Żadnego rzutowania. Wszystko jest wyinferowane end-to-end.

Setup z Next.js App Router#

Zbudujmy to od zera. Zakładam, że masz projekt Next.js 14+ z App Routerem.

Instalacja zależności#

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

Krok 1: Inicjalizacja tRPC na serwerze#

Utwórz instancję tRPC i zdefiniuj typ contextu.

typescript
// 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";
 
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const session = await getSession(opts.req);
 
  return {
    session,
    db: prisma,
    req: opts.req,
  };
}
 
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 createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;

Kilka rzeczy do zauważenia:

  • Transformer superjson: tRPC domyślnie serializuje dane jako JSON, co oznacza, że obiekty Date, Map, Set i inne nie-JSON-owe typy są tracone. superjson je zachowuje.
  • Error formatter: Dołączamy błędy walidacji Zod do odpowiedzi, żeby klient mógł pokazać błędy na poziomie pól.
  • createTRPCContext: Ta funkcja uruchamia się przy każdym żądaniu. To miejsce, gdzie parsujesz sesję, konfigurujesz połączenie z bazą danych i budujesz obiekt contextu.

Krok 2: Zdefiniuj swój Router#

typescript
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
 
export const userRouter = router({
  me: protectedProcedure.query(async ({ ctx }) => {
    const user = await ctx.db.user.findUnique({
      where: { id: ctx.user.id },
      select: {
        id: true,
        name: true,
        email: true,
        image: true,
        createdAt: true,
      },
    });
 
    if (!user) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "User not found",
      });
    }
 
    return user;
  }),
 
  updateProfile: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(100),
        bio: z.string().max(500).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const updated = await ctx.db.user.update({
        where: { id: ctx.user.id },
        data: {
          name: input.name,
          bio: input.bio,
        },
      });
 
      return updated;
    }),
 
  byId: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id },
        select: {
          id: true,
          name: true,
          image: true,
          bio: true,
        },
      });
 
      if (!user) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "User not found",
        });
      }
 
      return user;
    }),
});
typescript
// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
import { notificationRouter } from "./notification";
 
export const appRouter = router({
  user: userRouter,
  post: postRouter,
  notification: notificationRouter,
});
 
export type AppRouter = typeof appRouter;

Krok 3: Wystawienie przez Next.js Route Handler#

W App Router tRPC działa jako standardowy Route Handler. Żadnego niestandardowego serwera, żadnej specjalnej wtyczki Next.js.

typescript
// 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,
    onError:
      process.env.NODE_ENV === "development"
        ? ({ path, error }) => {
            console.error(
              `❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
            );
          }
        : undefined,
  });
 
export { handler as GET, handler as POST };

To wszystko. Zarówno GET, jak i POST są obsługiwane. Query przechodzą przez GET (z zakodowanym w URL inputem), mutacje przez POST.

Krok 4: Konfiguracja klienta#

typescript
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
 
export const trpc = createTRPCReact<AppRouter>();

Uwaga: importujemy AppRouter wyłącznie jako typ. Żaden kod serwerowy nie wycieknie do bundle'a klienta.

Krok 5: Setup providera#

typescript
// src/components/providers/TRPCProvider.tsx
"use client";
 
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
 
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 TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000,
            retry: 1,
          },
        },
      })
  );
 
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      ],
    })
  );
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
typescript
// src/app/layout.tsx (relevant part)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

Krok 6: Użycie w komponentach#

typescript
// src/app/dashboard/page.tsx
"use client";
 
import { trpc } from "@/lib/trpc";
 
export default function DashboardPage() {
  const { data: user, isLoading, error } = trpc.user.me.useQuery();
 
  if (isLoading) return <DashboardSkeleton />;
  if (error) return <ErrorDisplay message={error.message} />;
 
  return (
    <div>
      <h1>Welcome back, {user.name}</h1>
      <p>Member since {user.createdAt.toLocaleDateString()}</p>
    </div>
  );
}

To user.name jest w pełni typowane. Jeśli popełnisz literówkę i napiszesz user.nme, TypeScript natychmiast to złapie. Jeśli zmienisz serwer, żeby zwracał displayName zamiast name, każde użycie po stronie klienta pokaże błąd kompilacji. Żadnych niespodzianek w runtime.

Context i Middleware#

Context i middleware to miejsca, gdzie tRPC przechodzi od „fajnej sztuczki z typami" do „gotowego na produkcję frameworka".

Tworzenie contextu#

Funkcja contextu uruchamia się przy każdym żądaniu. Oto realna wersja:

typescript
// src/server/trpc.ts
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
 
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const session = await getServerSession(authOptions);
 
  return {
    session,
    user: session?.user ?? null,
    db: prisma,
    headers: Object.fromEntries(opts.req.headers),
  };
}
 
type Context = Awaited<ReturnType<typeof createTRPCContext>>;

Middleware auth#

Najpopularniejszy wzorzec middleware to rozdzielenie procedur publicznych od chronionych:

typescript
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to perform this action",
    });
  }
 
  return next({
    ctx: {
      // Override the context type — user is no longer nullable
      user: ctx.user,
    },
  });
});
 
export const protectedProcedure = t.procedure.use(isAuthed);

Po uruchomieniu tego middleware ctx.user w każdej protectedProcedure jest gwarantowanie nienulowy. System typów to wymusza. Nie możesz przypadkowo uzyskać dostępu do ctx.user.id w publicznej procedurze bez narzekania TypeScripta.

Middleware oparty na rolach#

Możesz komponować middleware dla bardziej granularnej kontroli dostępu:

typescript
const isAdmin = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
 
  if (ctx.user.role !== "ADMIN") {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Admin access required",
    });
  }
 
  return next({
    ctx: {
      user: ctx.user,
    },
  });
});
 
export const adminProcedure = t.procedure.use(isAdmin);

Middleware logowania#

Middleware to nie tylko auth. Oto middleware do logowania wydajności:

typescript
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
 
  const result = await next();
 
  const duration = Date.now() - start;
 
  if (duration > 1000) {
    console.warn(`⚠️ Slow ${type} ${path}: ${duration}ms`);
  }
 
  return result;
});
 
// Apply to all procedures
export const publicProcedure = t.procedure.use(loggerMiddleware);

Middleware rate limitingu#

typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, "10 s"),
});
 
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
  const ip = ctx.headers["x-forwarded-for"] ?? "unknown";
  const { success, remaining } = await ratelimit.limit(ip);
 
  if (!success) {
    throw new TRPCError({
      code: "TOO_MANY_REQUESTS",
      message: `Rate limit exceeded. Try again later.`,
    });
  }
 
  return next();
});

Walidacja inputu z Zod#

tRPC używa Zod do walidacji inputu. To nie jest opcjonalna dekoracja — to mechanizm, który zapewnia bezpieczeństwo inputów zarówno po stronie klienta, jak i serwera.

Podstawowa walidacja#

typescript
const postRouter = router({
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1, "Title is required").max(200, "Title too long"),
        content: z.string().min(10, "Content must be at least 10 characters"),
        categoryId: z.string().uuid("Invalid category ID"),
        tags: z.array(z.string()).max(5, "Maximum 5 tags").default([]),
        published: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // input is fully typed:
      // {
      //   title: string;
      //   content: string;
      //   categoryId: string;
      //   tags: string[];
      //   published: boolean;
      // }
 
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),
});

Sztuczka z podwójną walidacją#

Oto coś subtelnego: walidacja Zod uruchamia się po obu stronach. Po stronie klienta tRPC waliduje input przed wysłaniem żądania. Jeśli input jest nieprawidłowy, żądanie nigdy nie opuszcza przeglądarki. Po stronie serwera ten sam schemat waliduje ponownie jako środek bezpieczeństwa.

Oznacza to, że dostajesz natychmiastową walidację po stronie klienta za darmo:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function CreatePostForm() {
  const [title, setTitle] = useState("");
  const utils = trpc.useUtils();
 
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      utils.post.list.invalidate();
    },
    onError: (error) => {
      // Zod errors arrive structured
      if (error.data?.zodError) {
        const fieldErrors = error.data.zodError.fieldErrors;
        // fieldErrors.title?: string[]
        // fieldErrors.content?: string[]
        console.log(fieldErrors);
      }
    },
  });
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        createPost.mutate({
          title,
          content: "...",
          categoryId: "...",
        });
      }}
    >
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      {createPost.error?.data?.zodError?.fieldErrors.title && (
        <span className="text-red-500">
          {createPost.error.data.zodError.fieldErrors.title[0]}
        </span>
      )}
      <button type="submit" disabled={createPost.isPending}>
        {createPost.isPending ? "Creating..." : "Create Post"}
      </button>
    </form>
  );
}

Złożone wzorce inputu#

typescript
// Discriminated unions
const searchInput = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("user"),
    query: z.string(),
    includeInactive: z.boolean().default(false),
  }),
  z.object({
    type: z.literal("post"),
    query: z.string(),
    category: z.string().optional(),
  }),
]);
 
// Pagination input reused across procedures
const paginationInput = z.object({
  cursor: z.string().nullish(),
  limit: z.number().min(1).max(100).default(20),
});
 
const postRouter = router({
  infiniteList: publicProcedure
    .input(
      z.object({
        ...paginationInput.shape,
        category: z.string().optional(),
        sortBy: z.enum(["newest", "popular", "trending"]).default("newest"),
      })
    )
    .query(async ({ ctx, input }) => {
      const { cursor, limit, category, sortBy } = input;
 
      const posts = await ctx.db.post.findMany({
        take: limit + 1,
        cursor: cursor ? { id: cursor } : undefined,
        where: category ? { categoryId: category } : undefined,
        orderBy:
          sortBy === "newest"
            ? { createdAt: "desc" }
            : sortBy === "popular"
              ? { likes: "desc" }
              : { score: "desc" },
      });
 
      let nextCursor: string | undefined;
      if (posts.length > limit) {
        const nextItem = posts.pop();
        nextCursor = nextItem?.id;
      }
 
      return {
        posts,
        nextCursor,
      };
    }),
});

Opcjonalne inputy#

Nie każda procedura potrzebuje inputu. Query często nie potrzebują:

typescript
const statsRouter = router({
  // No input needed
  overview: publicProcedure.query(async ({ ctx }) => {
    const [userCount, postCount, commentCount] = await Promise.all([
      ctx.db.user.count(),
      ctx.db.post.count(),
      ctx.db.comment.count(),
    ]);
 
    return { userCount, postCount, commentCount };
  }),
 
  // Optional filters
  detailed: publicProcedure
    .input(
      z
        .object({
          from: z.date().optional(),
          to: z.date().optional(),
        })
        .optional()
    )
    .query(async ({ ctx, input }) => {
      const where = {
        ...(input?.from && { createdAt: { gte: input.from } }),
        ...(input?.to && { createdAt: { lte: input.to } }),
      };
 
      return ctx.db.post.groupBy({
        by: ["categoryId"],
        where,
        _count: true,
      });
    }),
});

Obsługa błędów#

Obsługa błędów w tRPC jest ustrukturyzowana, type-safe i elegancko integruje się zarówno z semantyką HTTP, jak i interfejsem po stronie klienta.

Rzucanie błędów na serwerze#

typescript
import { TRPCError } from "@trpc/server";
 
const postRouter = router({
  delete: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .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 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 };
    }),
});

Kody błędów tRPC mapują się na kody statusu HTTP:

Kod tRPCStatus HTTPKiedy używać
BAD_REQUEST400Nieprawidłowy input poza walidacją Zod
UNAUTHORIZED401Niezalogowany
FORBIDDEN403Zalogowany, ale niewystarczające uprawnienia
NOT_FOUND404Zasób nie istnieje
CONFLICT409Duplikat zasobu
TOO_MANY_REQUESTS429Przekroczony rate limit
INTERNAL_SERVER_ERROR500Nieoczekiwany błąd serwera

Niestandardowe formatowanie błędów#

Pamiętasz error formatter z naszego setupu? Oto jak działa w praktyce:

typescript
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,
        // Add custom fields
        timestamp: new Date().toISOString(),
        requestId: crypto.randomUUID(),
      },
    };
  },
});

Obsługa błędów po stronie klienta#

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
 
export function DeletePostButton({ postId }: { postId: string }) {
  const utils = trpc.useUtils();
 
  const deletePost = trpc.post.delete.useMutation({
    onSuccess: () => {
      toast.success("Post deleted");
      utils.post.list.invalidate();
    },
    onError: (error) => {
      switch (error.data?.code) {
        case "FORBIDDEN":
          toast.error("You don't have permission to delete this post");
          break;
        case "NOT_FOUND":
          toast.error("This post no longer exists");
          utils.post.list.invalidate();
          break;
        default:
          toast.error("Something went wrong. Please try again.");
      }
    },
  });
 
  return (
    <button
      onClick={() => deletePost.mutate({ id: postId })}
      disabled={deletePost.isPending}
    >
      {deletePost.isPending ? "Deleting..." : "Delete"}
    </button>
  );
}

Globalna obsługa błędów#

Możesz skonfigurować globalny handler błędów, który łapie wszystkie nieobsłużone błędy tRPC:

typescript
// In your TRPCProvider
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        mutations: {
          onError: (error) => {
            // Global fallback for unhandled mutation errors
            if (error instanceof TRPCClientError) {
              if (error.data?.code === "UNAUTHORIZED") {
                // Redirect to login
                window.location.href = "/login";
                return;
              }
 
              toast.error(error.message);
            }
          },
        },
      },
    })
);

Mutacje i optimistic updates#

Mutacje to miejsce, gdzie tRPC naprawdę dobrze integruje się z TanStack Query. Spójrzmy na realny wzorzec: przycisk polubienia z optimistic updates.

Podstawowa mutacja#

typescript
// Server
const postRouter = router({
  toggleLike: protectedProcedure
    .input(z.object({ postId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.db.like.findUnique({
        where: {
          userId_postId: {
            userId: ctx.user.id,
            postId: input.postId,
          },
        },
      });
 
      if (existing) {
        await ctx.db.like.delete({
          where: { id: existing.id },
        });
        return { liked: false };
      }
 
      await ctx.db.like.create({
        data: {
          userId: ctx.user.id,
          postId: input.postId,
        },
      });
 
      return { liked: true };
    }),
});

Optimistic updates#

Użytkownik klika „Lubię to". Nie chcesz czekać 200ms na odpowiedź serwera, żeby zaktualizować UI. Optimistic updates rozwiązują ten problem: aktualizuj UI natychmiast, a potem cofnij, jeśli serwer odrzuci.

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
 
export function LikeButton({ postId, initialLiked, initialCount }: {
  postId: string;
  initialLiked: boolean;
  initialCount: number;
}) {
  const utils = trpc.useUtils();
 
  const toggleLike = trpc.post.toggleLike.useMutation({
    onMutate: async ({ postId }) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await utils.post.byId.cancel({ id: postId });
 
      // Snapshot the previous value
      const previousPost = utils.post.byId.getData({ id: postId });
 
      // Optimistically update the cache
      utils.post.byId.setData({ id: postId }, (old) => {
        if (!old) return old;
        return {
          ...old,
          liked: !old.liked,
          likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
        };
      });
 
      // Return the snapshot for rollback
      return { previousPost };
    },
 
    onError: (_error, { postId }, context) => {
      // Roll back to the previous value on error
      if (context?.previousPost) {
        utils.post.byId.setData({ id: postId }, context.previousPost);
      }
    },
 
    onSettled: (_data, _error, { postId }) => {
      // Always refetch after error or success to ensure server state
      utils.post.byId.invalidate({ id: postId });
    },
  });
 
  return (
    <button
      onClick={() => toggleLike.mutate({ postId })}
      className={initialLiked ? "text-red-500" : "text-gray-400"}
    >
      ♥ {initialCount}
    </button>
  );
}

Wzorzec jest zawsze ten sam:

  1. onMutate: Anuluj zapytania, zrób snapshot bieżących danych, zastosuj optimistic update, zwróć snapshot.
  2. onError: Cofnij używając snapshotu.
  3. onSettled: Unieważnij zapytanie, żeby się odświeżyło z serwera, niezależnie od sukcesu czy błędu.

Ten trzykrokowy taniec zapewnia, że UI jest zawsze responsywny i ostatecznie spójny z serwerem.

Unieważnianie powiązanych zapytań#

Po mutacji często musisz odświeżyć powiązane dane. useUtils() z tRPC robi to ergonomicznie:

typescript
const createComment = trpc.comment.create.useMutation({
  onSuccess: (_data, variables) => {
    // Invalidate the post's comment list
    utils.comment.listByPost.invalidate({ postId: variables.postId });
 
    // Invalidate the post itself (comment count changed)
    utils.post.byId.invalidate({ id: variables.postId });
 
    // Invalidate ALL post lists (comment counts in list views)
    utils.post.list.invalidate();
  },
});

Batching i subskrypcje#

HTTP batching#

Domyślnie tRPC z httpBatchLink łączy wiele jednoczesnych żądań w jedno wywołanie HTTP. Jeśli komponent się renderuje i odpala trzy zapytania:

typescript
function Dashboard() {
  const user = trpc.user.me.useQuery();
  const posts = trpc.post.list.useQuery({ limit: 10 });
  const stats = trpc.stats.overview.useQuery();
 
  // ...
}

Te trzy zapytania są automatycznie łączone w jedno żądanie HTTP: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...

Serwer przetwarza wszystkie trzy, zwraca wszystkie trzy wyniki w jednej odpowiedzi, a TanStack Query dystrybuuje wyniki do każdego hooka. Żadna konfiguracja nie jest potrzebna.

Możesz wyłączyć batching dla konkretnych wywołań, jeśli potrzebujesz:

typescript
// In your provider, use splitLink to route specific procedures differently
import { splitLink, httpBatchLink, httpLink } from "@trpc/client";
 
const [trpcClient] = useState(() =>
  trpc.createClient({
    links: [
      splitLink({
        condition: (op) => op.path === "post.infiniteList",
        true: httpLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
        false: httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
          maxURLLength: 2048,
        }),
      }),
    ],
  })
);

Subskrypcje WebSocket#

Dla funkcji czasu rzeczywistego tRPC obsługuje subskrypcje przez WebSockety. Wymaga to osobnego serwera WebSocket (Next.js natywnie nie obsługuje WebSocketów w Route Handlerach).

typescript
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
 
// In-memory event emitter (use Redis pub/sub in production)
import { EventEmitter } from "events";
 
const eventEmitter = new EventEmitter();
 
export const notificationRouter = router({
  onNew: protectedProcedure.subscription(({ ctx }) => {
    return observable<Notification>((emit) => {
      const handler = (notification: Notification) => {
        if (notification.userId === ctx.user.id) {
          emit.next(notification);
        }
      };
 
      eventEmitter.on("notification", handler);
 
      return () => {
        eventEmitter.off("notification", handler);
      };
    });
  }),
 
  // Mutation that triggers a notification
  markAsRead: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const notification = await ctx.db.notification.update({
        where: { id: input.id, userId: ctx.user.id },
        data: { readAt: new Date() },
      });
 
      return notification;
    }),
});

Po stronie klienta:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function NotificationBell() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  trpc.notification.onNew.useSubscription(undefined, {
    onData: (notification) => {
      setNotifications((prev) => [notification, ...prev]);
      toast.info(notification.message);
    },
    onError: (error) => {
      console.error("Subscription error:", error);
    },
  });
 
  return (
    <div>
      <span className="badge">{notifications.length}</span>
      {/* notification list UI */}
    </div>
  );
}

Dla transportu WebSocket potrzebujesz dedykowanego procesu serwera. Oto minimalny setup z biblioteką ws:

typescript
// ws-server.ts (separate process)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
 
const wss = new WebSocketServer({ port: 3001 });
 
const handler = applyWSSHandler({
  wss,
  router: appRouter,
  createContext: createTRPCContext,
});
 
wss.on("connection", (ws) => {
  console.log(`Connection opened (${wss.clients.size} total)`);
  ws.once("close", () => {
    console.log(`Connection closed (${wss.clients.size} total)`);
  });
});
 
process.on("SIGTERM", () => {
  handler.broadcastReconnectNotification();
  wss.close();
});
 
console.log("WebSocket server listening on ws://localhost:3001");

A klient potrzebuje wsLink dla subskrypcji:

typescript
import { wsLink, createWSClient } from "@trpc/client";
 
const wsClient = createWSClient({
  url: "ws://localhost:3001",
});
 
// Use splitLink to route subscriptions through WebSocket
const [trpcClient] = useState(() =>
  trpc.createClient({
    links: [
      splitLink({
        condition: (op) => op.type === "subscription",
        true: wsLink({ client: wsClient, transformer: superjson }),
        false: httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      }),
    ],
  })
);

Type-safe upload plików#

tRPC nie obsługuje uploadu plików natywnie. To protokół JSON-RPC — dane binarne nie są w jego kompetencjach. Ale możesz zbudować type-safe flow uploadu, łącząc tRPC z presigned URL.

Wzorzec:

  1. Klient prosi tRPC o presigned upload URL.
  2. tRPC waliduje żądanie, sprawdza uprawnienia, generuje URL.
  3. Klient uploaduje bezpośrednio do S3 używając presigned URL.
  4. Klient powiadamia tRPC, że upload jest zakończony.
typescript
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 
const s3 = new S3Client({ region: process.env.AWS_REGION });
 
export const uploadRouter = router({
  getPresignedUrl: protectedProcedure
    .input(
      z.object({
        filename: z.string().min(1).max(255),
        contentType: z.string().regex(/^(image|application)\//),
        size: z.number().max(10 * 1024 * 1024, "File must be under 10MB"),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
 
      const command = new PutObjectCommand({
        Bucket: process.env.S3_BUCKET!,
        Key: key,
        ContentType: input.contentType,
        ContentLength: input.size,
      });
 
      const presignedUrl = await getSignedUrl(s3, command, {
        expiresIn: 300, // 5 minutes
      });
 
      // Store pending upload in database
      const upload = await ctx.db.upload.create({
        data: {
          key,
          userId: ctx.user.id,
          filename: input.filename,
          contentType: input.contentType,
          size: input.size,
          status: "PENDING",
        },
      });
 
      return {
        uploadId: upload.id,
        presignedUrl,
        key,
      };
    }),
 
  confirmUpload: protectedProcedure
    .input(z.object({ uploadId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const upload = await ctx.db.upload.findUnique({
        where: { id: input.uploadId, userId: ctx.user.id },
      });
 
      if (!upload) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
 
      // Verify the file actually exists in S3
      // (optional but recommended)
 
      const confirmed = await ctx.db.upload.update({
        where: { id: upload.id },
        data: { status: "CONFIRMED" },
      });
 
      return {
        url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${confirmed.key}`,
      };
    }),
});

Po stronie klienta:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
 
export function FileUpload() {
  const [uploading, setUploading] = useState(false);
 
  const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
  const confirmUpload = trpc.upload.confirmUpload.useMutation();
 
  const handleFileChange = useCallback(
    async (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;
 
      setUploading(true);
 
      try {
        // Step 1: Get presigned URL from tRPC
        const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        });
 
        // Step 2: Upload directly to S3
        const uploadResponse = await fetch(presignedUrl, {
          method: "PUT",
          body: file,
          headers: {
            "Content-Type": file.type,
          },
        });
 
        if (!uploadResponse.ok) {
          throw new Error("Upload failed");
        }
 
        // Step 3: Confirm upload via tRPC
        const { url } = await confirmUpload.mutateAsync({ uploadId });
 
        toast.success(`File uploaded: ${url}`);
      } catch (error) {
        toast.error("Upload failed. Please try again.");
      } finally {
        setUploading(false);
      }
    },
    [getPresignedUrl, confirmUpload]
  );
 
  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
        accept="image/*"
      />
      {uploading && <p>Uploading...</p>}
    </div>
  );
}

Cały flow jest type-safe. Typ odpowiedzi presigned URL, typ upload ID, odpowiedź potwierdzenia — wszystko wyinferowane z definicji serwerowych. Jeśli dodasz nowe pole do odpowiedzi presigned URL, klient natychmiast o tym wie.

Wywołania serwerowe i React Server Components#

Z Next.js App Router często chcesz pobierać dane w Server Components. tRPC obsługuje to przez server-side callery:

typescript
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
 
// Usage in a Server Component
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
 
export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const ctx = await createTRPCContext({
    req: new Request("http://localhost"),
    resHeaders: new Headers(),
  });
  const caller = createCaller(ctx);
 
  const post = await caller.post.byId({ id: params.id });
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      {/* Client components for interactive parts */}
      <LikeButton postId={post.id} initialLiked={post.liked} />
      <CommentSection postId={post.id} />
    </article>
  );
}

To daje ci to, co najlepsze z obu światów: renderowane serwerowo początkowe dane z pełnym bezpieczeństwem typów i interaktywność po stronie klienta dla mutacji i funkcji czasu rzeczywistego.

Testowanie procedur tRPC#

Testowanie jest proste, bo procedury to po prostu funkcje. Nie musisz stawiać serwera HTTP.

typescript
// src/server/routers/user.test.ts
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "./routers/_app";
import { createCaller } from "./trpc";
 
describe("user router", () => {
  it("returns the current user profile", async () => {
    const caller = createCaller({
      user: { id: "user-1", email: "test@example.com", role: "USER" },
      db: prismaMock,
      session: mockSession,
      headers: {},
    });
 
    prismaMock.user.findUnique.mockResolvedValue({
      id: "user-1",
      name: "Test User",
      email: "test@example.com",
      image: null,
      createdAt: new Date("2026-01-01"),
    });
 
    const result = await caller.user.me();
 
    expect(result).toEqual({
      id: "user-1",
      name: "Test User",
      email: "test@example.com",
      image: null,
      createdAt: new Date("2026-01-01"),
    });
  });
 
  it("throws UNAUTHORIZED for unauthenticated requests", async () => {
    const caller = createCaller({
      user: null,
      db: prismaMock,
      session: null,
      headers: {},
    });
 
    await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
  });
 
  it("validates input with Zod", async () => {
    const caller = createCaller({
      user: { id: "user-1", email: "test@example.com", role: "USER" },
      db: prismaMock,
      session: mockSession,
      headers: {},
    });
 
    await expect(
      caller.user.updateProfile({
        name: "", // min length 1
      })
    ).rejects.toThrow();
  });
});

Żadnego mockowania warstw HTTP, żadnego supertest, żadnego dopasowywania ścieżek. Po prostu wywołaj funkcję i sprawdź wynik. To jedna z niedocenianych zalet tRPC: testowanie jest trywialnie proste, bo warstwa transportowa to szczegół implementacji.

Kiedy NIE używać tRPC#

tRPC nie jest uniwersalnym rozwiązaniem. Oto gdzie się sypie:

Publiczne API#

Jeśli budujesz API, które będą konsumować zewnętrzni deweloperzy, tRPC to zły wybór. Zewnętrzni konsumenci nie mają dostępu do twoich typów TypeScript. Potrzebują udokumentowanego, stabilnego kontraktu — OpenAPI/Swagger dla REST lub schematu GraphQL. Bezpieczeństwo typów tRPC działa tylko wtedy, gdy klient i serwer współdzielą ten sam codebase TypeScript.

Aplikacje mobilne (chyba że używasz TypeScript)#

Jeśli twoja aplikacja mobilna jest napisana w Swift, Kotlin lub Dart, tRPC nie ma nic do zaoferowania. Typy nie przekraczają granic języków. Teoretycznie mógłbyś wygenerować specyfikację OpenAPI z tras tRPC używając trpc-openapi, ale w tym momencie dodajesz ceremonię z powrotem. Po prostu użyj REST od początku.

Mikroserwisy#

tRPC zakłada jeden codebase TypeScript. Jeśli twój backend jest podzielony na wiele serwisów w różnych językach, tRPC nie pomoże z komunikacją między serwisami. Użyj gRPC, REST lub kolejek wiadomości do tego.

Duże zespoły z oddzielnymi repozytoriami frontend/backend#

Jeśli twój frontend i backend żyją w oddzielnych repozytoriach z oddzielnymi pipeline'ami deploy, tracisz kluczową zaletę tRPC. Współdzielenie typów wymaga monorepo albo współdzielonego pakietu. Możesz opublikować typ AppRouter jako pakiet npm, ale wtedy masz problem z wersjonowaniem, który REST + OpenAPI obsługuje naturalniej.

Kiedy potrzebujesz semantyki REST#

Jeśli potrzebujesz nagłówków cachowania HTTP, negocjacji treści, ETagów lub innych specyficznych dla REST funkcji, abstrakcja tRPC nad HTTP będzie ci przeszkadzać. tRPC traktuje HTTP jako szczegół transportowy, nie jako feature.

Framework decyzyjny#

Oto jak podejmuję decyzję:

ScenariuszRekomendacja
Fullstack TypeScript app w jednym repotRPC — maksymalna korzyść, minimalny narzut
Narzędzie wewnętrzne / panel adminatRPC — priorytetem jest szybkość developmentu
Publiczne API dla zewnętrznych deweloperówREST + OpenAPI — konsumenci potrzebują dokumentacji, nie typów
Klienci mobilni + web (mobilne nie w TS)REST lub GraphQL — potrzebne kontrakty niezależne od języka
Dużo real-time (chat, gry)Subskrypcje tRPC lub surowe WebSockety w zależności od złożoności
Osobne zespoły frontend/backendGraphQL — schemat jest kontraktem między zespołami

Praktyczne wskazówki z produkcji#

Kilka rzeczy, których nauczyłem się, prowadząc tRPC na produkcji, a których nie ma w dokumentacji:

Trzymaj routery małe. Pojedynczy plik routera nie powinien przekraczać 200 linii. Dziel według domeny: userRouter, postRouter, billingRouter. Każdy w osobnym pliku.

Używaj createCallerFactory do wywołań serwerowych. Nie sięgaj po fetch, kiedy wywołujesz własne API z Server Component. Caller factory daje ci to samo bezpieczeństwo typów z zerowym narzutem HTTP.

Nie przesadzaj z optymalizacją batchingu. Domyślny httpBatchLink prawie zawsze wystarcza. Widziałem zespoły spędzające dni na konfiguracji splitLink dla marginalnych zysków. Najpierw profiluj.

Ustaw staleTime w QueryClient. Domyślny staleTime wynoszący 0 oznacza, że każde zdarzenie focus wywołuje refetch. Ustaw go na coś rozsądnego (30 sekund do 5 minut) w zależności od wymagań dotyczących świeżości danych.

Używaj superjson od pierwszego dnia. Dodanie go później oznacza migrację każdego klienta i serwera jednocześnie. To jednolinijkowa konfiguracja, która ratuje cię przed bugami serializacji Date.

Error boundaries to twój przyjaciel. Owijaj sekcje strony intensywnie korzystające z tRPC w React error boundaries. Jedno nieudane zapytanie nie powinno zawalić całej strony.

typescript
"use client";
 
import { ErrorBoundary } from "react-error-boundary";
 
function ErrorFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert" className="p-4 bg-red-50 rounded-lg">
      <p className="font-medium text-red-800">Something went wrong</p>
      <pre className="text-sm text-red-600 mt-2">{error.message}</pre>
      <button
        onClick={resetErrorBoundary}
        className="mt-3 px-4 py-2 bg-red-600 text-white rounded"
      >
        Try again
      </button>
    </div>
  );
}
 
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <UserStats />
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <RecentPosts />
      </ErrorBoundary>
    </div>
  );
}

Podsumowanie#

tRPC nie jest zamiennikiem REST czy GraphQL. To inne narzędzie do konkretnej sytuacji: kiedy kontrolujesz zarówno klienta, jak i serwer, oba są w TypeScript i chcesz najkrótszą możliwą drogę od „zmieniłem backend" do „frontend o tym wie".

W tej sytuacji nic innego się nie zbliża. Żadnego generowania kodu, żadnych plików schematu, żadnego dryfu. Po prostu TypeScript robiący to, w czym jest najlepszy: łapanie błędów zanim trafią na produkcję.

Kompromis jest jasny: rezygnujesz z interoperacyjności na poziomie protokołu (brak klientów spoza TypeScript) w zamian za szybkość developmentu i bezpieczeństwo w czasie kompilacji, które trudno osiągnąć w inny sposób.

Dla większości fullstackowych aplikacji TypeScript to kompromis wart podjęcia.

Powiązane wpisy