Přeskočit na obsah
·24 min čtení

tRPC: End-to-end typová bezpečnost bez ceremonií

Jak tRPC eliminuje problém API kontraktu, funguje s Next.js App Router, řeší auth middleware, nahrávání souborů a subscriptions. Reálný tRPC setup od nuly.

Sdílet:X / TwitterLinkedIn

Znáte to. Změníte název pole v odpovědi API. Aktualizujete backendový typ. Nasadíte. A pak se frontend rozbije v produkci, protože někdo zapomněl aktualizovat fetch volání na řádku 247 v Dashboard.tsx. Pole je teď undefined, komponenta se vykreslí prázdná a váš error tracking se rozsvítí ve 2 ráno.

Toto je problém API kontraktu. Není to technologický problém. Je to koordinační problém. A žádné množství Swagger dokumentace nebo GraphQL schémat neopraví skutečnost, že vaše frontendové a backendové typy se mohou tiše rozsynchronizovat.

tRPC toto řeší tím, že jim nedovolí se rozsynchronizovat. Žádný soubor schématu. Žádný krok generování kódu. Žádný samostatný kontrakt k údržbě. Napíšete TypeScriptovou funkci na serveru a klient zná její přesné vstupní a výstupní typy v době kompilace. Pokud přejmenujete pole, frontend se nezkompiluje, dokud to neopravíte.

To je ten slib. Ukážu vám, jak to skutečně funguje, kde to září a kde byste to rozhodně neměli používat.

Problém, přesněji#

Podívejme se, jak většina týmů dnes staví API.

REST + OpenAPI: Napíšete své endpointy. Možná přidáte Swagger anotace. Možná vygenerujete klientské SDK z OpenAPI specifikace. Ale specifikace je samostatný artefakt. Může zastarit. Generovací krok je další věc ve vašem CI pipeline, která se může rozbít nebo být zapomenuta. A generované typy jsou často ošklivé — hluboce vnořené příšery typu paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"].

GraphQL: Lepší typová bezpečnost, ale enormní ceremonie. Napíšete schéma v SDL. Napíšete resolvery. Vygenerujete typy ze schématu. Napíšete dotazy na klientu. Vygenerujete typy z dotazů. To jsou minimálně dva kroky generování kódu, soubor schématu a build krok, na který si každý musí vzpomenout. Pro tým, který již ovládá jak frontend, tak backend, je to hodně infrastruktury pro problém, který má jednodušší řešení.

Manuální fetch volání: Nejběžnější přístup a nejnebezpečnější. Napíšete fetch("/api/users"), přetypujete výsledek na User[] a doufáte v to nejlepší. Nulová bezpečnost v době kompilace. Typová aserce je lež, kterou říkáte TypeScriptu.

typescript
// Lež, kterou řekl každý frontend vývojář
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // doufáme, že je to správně

tRPC volí zcela odlišný přístup. Místo popisu vašeho API v samostatném formátu a generování typů píšete prosté TypeScriptové funkce na serveru a importujete jejich typy přímo do klienta. Žádný generovací krok. Žádný soubor schématu. Žádná desynchronizace.

Základní koncepty#

Než cokoli nastavíme, pochopme mentální model.

Router#

tRPC router je kolekce procedur seskupených dohromady. Představte si ho jako controller v MVC, až na to, že je to prostý objekt s vestavěnou typovou inferencí.

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 export typu AppRouter je celý kouzelný trik. Klient importuje tento typ — ne runtime kód, jen typ — a dostane plné automatické doplňování a kontrolu typů pro každou proceduru.

Procedura#

Procedura je jeden endpoint. Existují tři druhy:

  • Query: Operace čtení. Mapuje se na HTTP GET sémantiku. Cachováno TanStack Query.
  • Mutation: Operace zápisu. Mapuje se na HTTP POST. Není cachováno.
  • Subscription: Streamy v reálném čase. Používá WebSockety.

Kontext#

Kontext jsou data s rozsahem požadavku dostupná každé proceduře. Databázová připojení, autentizovaný uživatel, hlavičky požadavku — cokoli byste dali do objektu req v Expressu patří sem.

Middleware#

Middleware transformuje kontext nebo chrání přístup. Nejběžnější pattern je auth middleware, který kontroluje platnou session a přidává ctx.user.

Řetězec typové inference#

Toto je kritický mentální model. Když definujete proceduru takto:

typescript
t.procedure
  .input(z.object({ id: z.string() }))
  .query(({ input }) => {
    // input je typován jako { id: string }
    return db.user.findUnique({ where: { id: input.id } });
  });

Návratový typ protéká celou cestou až ke klientu. Pokud db.user.findUnique vrací User | null, hook useQuery na klientu bude mít data typováno jako User | null. Žádné manuální typování. Žádné přetypování. Je to inferováno end-to-end.

Setup s Next.js App Router#

Postavíme to od nuly. Předpokládám, že máte Next.js 14+ projekt s App Routerem.

Instalace závislostí#

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

Krok 1: Inicializace tRPC na serveru#

Vytvořte svou tRPC instanci a definujte typ kontextu.

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;

Pár věcí k povšimnutí:

  • superjson transformer: tRPC serializuje data jako JSON ve výchozím nastavení, což znamená, že objekty Date, Map, Set a další ne-JSON typy se ztratí. superjson je zachovává.
  • Error formatter: Připojujeme Zod validační chyby k odpovědi, aby klient mohl zobrazit chyby na úrovni polí.
  • createTRPCContext: Tato funkce běží na každém požadavku. Zde parsujete session, nastavujete databázové připojení a budujete objekt kontextu.

Krok 2: Definujte svů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: Vystavení přes Next.js Route Handler#

V App Routeru běží tRPC jako standardní Route Handler. Žádný vlastní server, žádný speciální Next.js plugin.

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 je vše. Jak GET, tak POST jsou ošetřeny. Query jdou přes GET (s URL-kódovaným vstupem), mutace jdou přes POST.

Krok 4: Nastavení klienta#

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

Poznámka: importujeme AppRouter pouze jako typ. Žádný serverový kód neuniká do klientského bundlu.

Krok 5: Nastavení provideru#

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 (relevantní část)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

Krok 6: Použití v komponentách#

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>Vítejte zpět, {user.name}</h1>
      <p>Členem od {user.createdAt.toLocaleDateString()}</p>
    </div>
  );
}

Ten user.name je plně typovaný. Pokud ho překlepnete jako user.nme, TypeScript to okamžitě zachytí. Pokud změníte server tak, aby vracel displayName místo name, každé použití na klientu ukáže chybu kompilace. Žádná runtime překvapení.

Kontext a middleware#

Kontext a middleware jsou tam, kde se tRPC posouvá z „šikovného typového triku" na „framework připravený pro produkci."

Vytvoření kontextu#

Funkce kontextu běží na každém požadavku. Zde je reálná verze:

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

Auth middleware#

Nejběžnější pattern middleware je oddělení veřejných a chráněných procedur:

typescript
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "Pro provedení této akce musíte být přihlášeni",
    });
  }
 
  return next({
    ctx: {
      // Přepíšeme typ kontextu — user již není nullable
      user: ctx.user,
    },
  });
});
 
export const protectedProcedure = t.procedure.use(isAuthed);

Po spuštění tohoto middleware je ctx.user v jakékoli protectedProcedure garantovaně non-null. Typový systém to vynucuje. Nemůžete omylem přistupovat k ctx.user.id ve veřejné proceduře bez toho, aby si TypeScript stěžoval.

Middleware na základě rolí#

Middleware můžete skládat pro podrobnější kontrolu přístupu:

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: "Vyžadován administrátorský přístup",
    });
  }
 
  return next({
    ctx: {
      user: ctx.user,
    },
  });
});
 
export const adminProcedure = t.procedure.use(isAdmin);

Logovací middleware#

Middleware není jen pro autentizaci. Zde je middleware pro logování výkonu:

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(`Pomalý ${type} ${path}: ${duration}ms`);
  }
 
  return result;
});
 
// Aplikujeme na všechny procedury
export const publicProcedure = t.procedure.use(loggerMiddleware);

Middleware pro rate limiting#

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: `Překročen rate limit. Zkuste to později.`,
    });
  }
 
  return next();
});

Validace vstupu se Zod#

tRPC používá Zod pro validaci vstupu. To není volitelná dekorace — je to mechanismus, který zajišťuje, že vstupy jsou bezpečné jak na klientu, tak na serveru.

Základní validace#

typescript
const postRouter = router({
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1, "Název je povinný").max(200, "Název je příliš dlouhý"),
        content: z.string().min(10, "Obsah musí mít alespoň 10 znaků"),
        categoryId: z.string().uuid("Neplatné ID kategorie"),
        tags: z.array(z.string()).max(5, "Maximálně 5 tagů").default([]),
        published: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // input je plně typovaný:
      // {
      //   title: string;
      //   content: string;
      //   categoryId: string;
      //   tags: string[];
      //   published: boolean;
      // }
 
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),
});

Trik dvojité validace#

Zde je něco subtilního: Zod validace běží na obou stranách. Na klientu tRPC validuje vstup před odesláním požadavku. Pokud je vstup neplatný, požadavek nikdy neopustí prohlížeč. Na serveru stejné schéma validuje znovu jako bezpečnostní opatření.

To znamená, že získáte okamžitou validaci na straně klienta zdarma:

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 chyby přicházejí strukturovaně
      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 ? "Vytvářím..." : "Vytvořit příspěvek"}
      </button>
    </form>
  );
}

Složité vstupní patterny#

typescript
// Diskriminované unie
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(),
  }),
]);
 
// Vstup pro stránkování znovupoužitelný napříč procedurami
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,
      };
    }),
});

Volitelné vstupy#

Ne každá procedura potřebuje vstup. Query často nepotřebují:

typescript
const statsRouter = router({
  // Žádný vstup není potřeba
  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 };
  }),
 
  // Volitelné filtry
  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,
      });
    }),
});

Ošetření chyb#

Ošetření chyb v tRPC je strukturované, typově bezpečné a čistě se integruje jak s HTTP sémantikou, tak s klientským UI.

Vyvolání chyb na serveru#

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: "Příspěvek nenalezen",
        });
      }
 
      if (post.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "Můžete mazat pouze vlastní příspěvky",
        });
      }
 
      await ctx.db.post.delete({ where: { id: input.id } });
 
      return { success: true };
    }),
});

Chybové kódy tRPC se mapují na HTTP stavové kódy:

tRPC kódHTTP stavKdy použít
BAD_REQUEST400Neplatný vstup mimo Zod validaci
UNAUTHORIZED401Nepřihlášen
FORBIDDEN403Přihlášen, ale nedostatečná oprávnění
NOT_FOUND404Zdroj neexistuje
CONFLICT409Duplicitní zdroj
TOO_MANY_REQUESTS429Překročen rate limit
INTERNAL_SERVER_ERROR500Neočekávaná chyba serveru

Vlastní formátování chyb#

Pamatujete si error formatter z našeho setupu? Takto funguje v praxi:

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,
        // Přidáme vlastní pole
        timestamp: new Date().toISOString(),
        requestId: crypto.randomUUID(),
      },
    };
  },
});

Ošetření chyb na straně 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("Příspěvek smazán");
      utils.post.list.invalidate();
    },
    onError: (error) => {
      switch (error.data?.code) {
        case "FORBIDDEN":
          toast.error("Nemáte oprávnění smazat tento příspěvek");
          break;
        case "NOT_FOUND":
          toast.error("Tento příspěvek již neexistuje");
          utils.post.list.invalidate();
          break;
        default:
          toast.error("Něco se pokazilo. Zkuste to znovu.");
      }
    },
  });
 
  return (
    <button
      onClick={() => deletePost.mutate({ id: postId })}
      disabled={deletePost.isPending}
    >
      {deletePost.isPending ? "Mažu..." : "Smazat"}
    </button>
  );
}

Globální ošetření chyb#

Můžete nastavit globální handler chyb, který zachytí všechny neošetřené tRPC chyby:

typescript
// Ve vašem TRPCProvider
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        mutations: {
          onError: (error) => {
            // Globální fallback pro neošetřené chyby mutací
            if (error instanceof TRPCClientError) {
              if (error.data?.code === "UNAUTHORIZED") {
                // Přesměrování na přihlášení
                window.location.href = "/login";
                return;
              }
 
              toast.error(error.message);
            }
          },
        },
      },
    })
);

Mutace a optimistické aktualizace#

Mutace jsou tam, kde se tRPC skutečně dobře integruje s TanStack Query. Podívejme se na reálný pattern: tlačítko líbí se s optimistickými aktualizacemi.

Základní mutace#

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

Optimistické aktualizace#

Uživatel klikne na „Líbí se." Nechcete čekat 200ms na odpověď serveru k aktualizaci UI. Optimistické aktualizace toto řeší: aktualizujte UI okamžitě, pak rollback, pokud server odmítne.

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 }) => {
      // Zrušíme odchozí refetche, aby nepřepsaly naši optimistickou aktualizaci
      await utils.post.byId.cancel({ id: postId });
 
      // Snapshot předchozí hodnoty
      const previousPost = utils.post.byId.getData({ id: postId });
 
      // Optimisticky aktualizujeme 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,
        };
      });
 
      // Vrátíme snapshot pro rollback
      return { previousPost };
    },
 
    onError: (_error, { postId }, context) => {
      // Rollback na předchozí hodnotu při chybě
      if (context?.previousPost) {
        utils.post.byId.setData({ id: postId }, context.previousPost);
      }
    },
 
    onSettled: (_data, _error, { postId }) => {
      // Vždy refetch po chybě nebo úspěchu pro zajištění stavu serveru
      utils.post.byId.invalidate({ id: postId });
    },
  });
 
  return (
    <button
      onClick={() => toggleLike.mutate({ postId })}
      className={initialLiked ? "text-red-500" : "text-gray-400"}
    >
      ♥ {initialCount}
    </button>
  );
}

Pattern je vždy stejný:

  1. onMutate: Zrušte dotazy, udělejte snapshot aktuálních dat, aplikujte optimistickou aktualizaci, vraťte snapshot.
  2. onError: Rollback pomocí snapshotu.
  3. onSettled: Invalidujte dotaz, aby se znovu načetl ze serveru, bez ohledu na úspěch nebo chybu.

Tento třístupňový tanec zajišťuje, že UI je vždy responzivní a nakonec konzistentní se serverem.

Invalidace souvisejících dotazů#

Po mutaci často potřebujete obnovit související data. useUtils() v tRPC to činí ergonomickým:

typescript
const createComment = trpc.comment.create.useMutation({
  onSuccess: (_data, variables) => {
    // Invalidujeme seznam komentářů příspěvku
    utils.comment.listByPost.invalidate({ postId: variables.postId });
 
    // Invalidujeme samotný příspěvek (počet komentářů se změnil)
    utils.post.byId.invalidate({ id: variables.postId });
 
    // Invalidujeme VŠECHNY seznamy příspěvků (počty komentářů v seznamech)
    utils.post.list.invalidate();
  },
});

Dávkování a subscriptions#

HTTP dávkování#

Ve výchozím nastavení tRPC s httpBatchLink kombinuje více současných požadavků do jednoho HTTP volání. Pokud se komponenta vykreslí a spustí tři dotazy:

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

Tyto tři dotazy jsou automaticky sdávkovány do jednoho HTTP požadavku: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...

Server zpracuje všechny tři, vrátí všechny tři výsledky v jedné odpovědi a TanStack Query distribuuje výsledky každému hooku. Žádná konfigurace potřeba.

Dávkování můžete zakázat pro specifická volání, pokud je potřeba:

typescript
// Ve vašem provideru, použijte splitLink pro směrování specifických procedur jinak
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,
        }),
      }),
    ],
  })
);

WebSocket subscriptions#

Pro real-time funkce tRPC podporuje subscriptions přes WebSockety. To vyžaduje samostatný WebSocket server (Next.js nativně nepodporuje WebSockety v Route Handlerech).

typescript
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
 
// In-memory event emitter (v produkci použijte Redis pub/sub)
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);
      };
    });
  }),
 
  // Mutace, která spustí notifikaci
  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;
    }),
});

Na klientu:

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("Chyba subscripce:", error);
    },
  });
 
  return (
    <div>
      <span className="badge">{notifications.length}</span>
      {/* UI seznamu notifikací */}
    </div>
  );
}

Pro WebSocket transport potřebujete dedikovaný serverový proces. Zde je minimální setup s knihovnou ws:

typescript
// ws-server.ts (samostatný proces)
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(`Připojení otevřeno (${wss.clients.size} celkem)`);
  ws.once("close", () => {
    console.log(`Připojení uzavřeno (${wss.clients.size} celkem)`);
  });
});
 
process.on("SIGTERM", () => {
  handler.broadcastReconnectNotification();
  wss.close();
});
 
console.log("WebSocket server naslouchá na ws://localhost:3001");

A klient potřebuje wsLink pro subscriptions:

typescript
import { wsLink, createWSClient } from "@trpc/client";
 
const wsClient = createWSClient({
  url: "ws://localhost:3001",
});
 
// Použijte splitLink pro směrování subscriptions přes 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,
        }),
      }),
    ],
  })
);

Typově bezpečné nahrávání souborů#

tRPC nativně neřeší nahrávání souborů. Je to JSON-RPC protokol — binární data nejsou jeho doménou. Ale můžete vybudovat typově bezpečný tok nahrávání kombinací tRPC s presigned URL.

Pattern:

  1. Klient požádá tRPC o presigned upload URL.
  2. tRPC validuje požadavek, zkontroluje oprávnění, vygeneruje URL.
  3. Klient nahrává přímo na S3 pomocí presigned URL.
  4. Klient oznámí tRPC, že nahrávání je dokončeno.
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, "Soubor musí být menší než 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 minut
      });
 
      // Uložíme čekající upload do databáze
      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" });
      }
 
      // Ověříme, že soubor skutečně existuje v S3
      // (volitelné, ale doporučené)
 
      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}`,
      };
    }),
});

Na klientu:

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 {
        // Krok 1: Získáme presigned URL z tRPC
        const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        });
 
        // Krok 2: Nahrajeme přímo na S3
        const uploadResponse = await fetch(presignedUrl, {
          method: "PUT",
          body: file,
          headers: {
            "Content-Type": file.type,
          },
        });
 
        if (!uploadResponse.ok) {
          throw new Error("Nahrávání selhalo");
        }
 
        // Krok 3: Potvrdíme upload přes tRPC
        const { url } = await confirmUpload.mutateAsync({ uploadId });
 
        toast.success(`Soubor nahrán: ${url}`);
      } catch (error) {
        toast.error("Nahrávání selhalo. Zkuste to znovu.");
      } finally {
        setUploading(false);
      }
    },
    [getPresignedUrl, confirmUpload]
  );
 
  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
        accept="image/*"
      />
      {uploading && <p>Nahrávám...</p>}
    </div>
  );
}

Celý tok je typově bezpečný. Typ odpovědi presigned URL, typ upload ID, odpověď potvrzení — vše inferováno ze serverových definic. Pokud přidáte nové pole do odpovědi presigned URL, klient o něm okamžitě ví.

Serverová volání a React Server Components#

S Next.js App Routerem často chcete načíst data v Server Components. tRPC toto podporuje přes serverové callery:

typescript
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
 
// Použití v 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>
      {/* Klientské komponenty pro interaktivní části */}
      <LikeButton postId={post.id} initialLiked={post.liked} />
      <CommentSection postId={post.id} />
    </article>
  );
}

Toto vám dává to nejlepší z obou světů: server-renderovaná počáteční data s plnou typovou bezpečností a klientská interaktivita pro mutace a real-time funkce.

Testování tRPC procedur#

Testování je přímočaré, protože procedury jsou jen funkce. Nemusíte spouštět HTTP server.

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("vrátí profil aktuálního uživatele", 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("vyhodí UNAUTHORIZED pro neautentizované požadavky", async () => {
    const caller = createCaller({
      user: null,
      db: prismaMock,
      session: null,
      headers: {},
    });
 
    await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
  });
 
  it("validuje vstup se 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: "", // minimální délka 1
      })
    ).rejects.toThrow();
  });
});

Žádné mockování HTTP vrstev, žádný supertest, žádné route matching. Prostě zavolejte funkci a asertujte výsledek. Toto je jedna z nedoceněných výhod tRPC: testování je triviálně jednoduché, protože transportní vrstva je implementační detail.

Kdy tRPC NEPOUŽÍVAT#

tRPC není univerzální řešení. Zde je, kde selhává:

Veřejná API#

Pokud stavíte API, které budou konzumovat externí vývojáři, tRPC je špatná volba. Externí konzumenti nemají přístup k vašim TypeScriptovým typům. Potřebují dokumentovaný, stabilní kontrakt — OpenAPI/Swagger pro REST, nebo GraphQL schéma. Typová bezpečnost tRPC funguje jen tehdy, když klient i server sdílejí stejný TypeScriptový codebase.

Mobilní aplikace (pokud nepoužíváte TypeScript)#

Pokud je vaše mobilní aplikace napsaná ve Swiftu, Kotlinu nebo Dartu, tRPC nenabízí nic. Typy nepřecházejí přes hranice jazyků. Teoreticky byste mohli vygenerovat OpenAPI specifikaci z tRPC rout pomocí trpc-openapi, ale v tom bodě přidáváte ceremonie zpět. Prostě použijte REST od začátku.

Mikroslužby#

tRPC předpokládá jeden TypeScriptový codebase. Pokud je váš backend rozdělen do více služeb v různých jazycích, tRPC nepomůže s mezislužební komunikací. Pro to použijte gRPC, REST nebo fronty zpráv.

Velké týmy se samostatnými frontend/backend repozitáři#

Pokud váš frontend a backend žijí v samostatných repozitářích se samostatnými deploy pipeline, ztrácíte hlavní výhodu tRPC. Sdílení typů vyžaduje monorepo nebo sdílený balíček. Můžete publikovat typ AppRouter jako npm balíček, ale teď máte problém s verzováním, který REST + OpenAPI řeší přirozeněji.

Když potřebujete REST sémantiku#

Pokud potřebujete HTTP caching hlavičky, content negotiation, ETagy nebo jiné REST-specifické funkce, abstrakce tRPC nad HTTP bude bojovat proti vám. tRPC zachází s HTTP jako s transportním detailem, ne jako s funkcí.

Rozhodovací rámec#

Takto se rozhoduji:

ScénářDoporučení
Fullstack TypeScript aplikace ve stejném repozitářitRPC — maximální přínos, minimální overhead
Interní nástroj / administrační paneltRPC — rychlost vývoje je priorita
Veřejné API pro vývojáře třetích stranREST + OpenAPI — konzumenti potřebují dokumentaci, ne typy
Mobilní + weboví klienti (non-TS mobilní)REST nebo GraphQL — potřeba jazykově nezávislé kontrakty
Hodně real-time (chat, gaming)tRPC subscriptions nebo raw WebSockety podle složitosti
Oddělené frontend/backend týmyGraphQL — schéma je kontrakt mezi týmy

Praktické tipy z produkce#

Pár věcí, které jsem se naučil z provozování tRPC v produkci a které nejsou v dokumentaci:

Udržujte routery malé. Jeden soubor routeru by neměl překročit 200 řádků. Dělte podle domény: userRouter, postRouter, billingRouter. Každý ve vlastním souboru.

Používejte createCallerFactory pro serverová volání. Nesahejte po fetch, když voláte vlastní API ze Server Component. Caller factory vám dává stejnou typovou bezpečnost s nulovým HTTP overheadem.

Nepřeoptimalizujte dávkování. Výchozí httpBatchLink je téměř vždy dostatečný. Viděl jsem týmy trávit dny nastavováním konfigurací splitLink pro marginální zisky. Nejdřív profilujte.

Nastavte staleTime v QueryClient. Výchozí staleTime 0 znamená, že každá událost focus spustí refetch. Nastavte ho na něco rozumného (30 sekund až 5 minut) na základě vašich požadavků na čerstvost dat.

Používejte superjson od prvního dne. Přidání později znamená migraci každého klienta a serveru současně. Je to konfigurace na jeden řádek, která vás zachrání od bugů serializace Date.

Error boundaries jsou váš přítel. Obalte sekce stránek náročné na tRPC v React error boundaries. Jeden neúspěšný dotaz by neměl shodit celou stránku.

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">Něco se pokazilo</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"
      >
        Zkusit znovu
      </button>
    </div>
  );
}
 
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <UserStats />
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <RecentPosts />
      </ErrorBoundary>
    </div>
  );
}

Závěr#

tRPC není náhradou za REST nebo GraphQL. Je to odlišný nástroj pro specifickou situaci: když ovládáte jak klienta, tak server, oba jsou TypeScript a chcete nejkratší možnou cestu od „změnil jsem backend" k „frontend o tom ví."

V té situaci se tomu nic jiné nepřiblíží. Žádné generování kódu, žádné soubory schémat, žádná desynchronizace. Prostě TypeScript dělá to, co umí nejlépe: chytá chyby dříve, než se dostanou do produkce.

Kompromis je jasný: vzdáváte se interoperability na úrovni protokolu (žádní non-TypeScript klienti) výměnou za rychlost vývoje a bezpečnost v době kompilace, které je obtížné dosáhnout jinak.

Pro většinu fullstack TypeScript aplikací je to kompromis, který stojí za to udělat.

Související články