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.
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.
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.
// 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.
Než cokoli nastavíme, pochopme mentální model.
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í.
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 je jeden endpoint. Existují tři druhy:
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 transformuje kontext nebo chrání přístup. Nejběžnější pattern je auth middleware, který kontroluje platnou session a přidává ctx.user.
Toto je kritický mentální model. Když definujete proceduru takto:
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.
Postavíme to od nuly. Předpokládám, že máte Next.js 14+ projekt s App Routerem.
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodVytvořte svou tRPC instanci a definujte typ kontextu.
// 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á.createTRPCContext: Tato funkce běží na každém požadavku. Zde parsujete session, nastavujete databázové připojení a budujete objekt kontextu.// 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;
}),
});// 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;V App Routeru běží tRPC jako standardní Route Handler. Žádný vlastní server, žádný speciální Next.js plugin.
// 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.
// 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.
// 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>
);
}// 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>
);
}// 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 jsou tam, kde se tRPC posouvá z „šikovného typového triku" na „framework připravený pro produkci."
Funkce kontextu běží na každém požadavku. Zde je reálná verze:
// 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>>;Nejběžnější pattern middleware je oddělení veřejných a chráněných procedur:
// 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 můžete skládat pro podrobnější kontrolu přístupu:
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);Middleware není jen pro autentizaci. Zde je middleware pro logování výkonu:
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);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();
});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.
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,
},
});
}),
});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:
"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>
);
}// 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,
};
}),
});Ne každá procedura potřebuje vstup. Query často nepotřebují:
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 v tRPC je strukturované, typově bezpečné a čistě se integruje jak s HTTP sémantikou, tak s klientským UI.
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ód | HTTP stav | Kdy použít |
|---|---|---|
BAD_REQUEST | 400 | Neplatný vstup mimo Zod validaci |
UNAUTHORIZED | 401 | Nepřihlášen |
FORBIDDEN | 403 | Přihlášen, ale nedostatečná oprávnění |
NOT_FOUND | 404 | Zdroj neexistuje |
CONFLICT | 409 | Duplicitní zdroj |
TOO_MANY_REQUESTS | 429 | Překročen rate limit |
INTERNAL_SERVER_ERROR | 500 | Neočekávaná chyba serveru |
Pamatujete si error formatter z našeho setupu? Takto funguje v praxi:
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(),
},
};
},
});"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>
);
}Můžete nastavit globální handler chyb, který zachytí všechny neošetřené tRPC chyby:
// 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 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.
// 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 };
}),
});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.
"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ý:
onMutate: Zrušte dotazy, udělejte snapshot aktuálních dat, aplikujte optimistickou aktualizaci, vraťte snapshot.onError: Rollback pomocí snapshotu.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.
Po mutaci často potřebujete obnovit související data. useUtils() v tRPC to činí ergonomickým:
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();
},
});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:
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:
// 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,
}),
}),
],
})
);Pro real-time funkce tRPC podporuje subscriptions přes WebSockety. To vyžaduje samostatný WebSocket server (Next.js nativně nepodporuje WebSockety v Route Handlerech).
// 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:
"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:
// 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:
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,
}),
}),
],
})
);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:
// 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:
"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í.
S Next.js App Routerem často chcete načíst data v Server Components. tRPC toto podporuje přes serverové callery:
// 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í je přímočaré, protože procedury jsou jen funkce. Nemusíte spouštět HTTP server.
// 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.
tRPC není univerzální řešení. Zde je, kde selhává:
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.
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.
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.
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.
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í.
Takto se rozhoduji:
| Scénář | Doporučení |
|---|---|
| Fullstack TypeScript aplikace ve stejném repozitáři | tRPC — maximální přínos, minimální overhead |
| Interní nástroj / administrační panel | tRPC — rychlost vývoje je priorita |
| Veřejné API pro vývojáře třetích stran | REST + 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ýmy | GraphQL — schéma je kontrakt mezi týmy |
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.
"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>
);
}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.