tRPC: Наскрізна типобезпечність без церемоній
Як tRPC усуває проблему API-контракту, працює з Next.js App Router, обробляє auth middleware, завантаження файлів та підписки. Реальне налаштування tRPC з нуля.
Ви знаєте цю ситуацію. Ви змінюєте назву поля у відповіді API. Оновлюєте тип на бекенді. Деплоїте. А потім фронтенд ламається на продакшені, бо хтось забув оновити fetch-виклик у рядку 247 файлу Dashboard.tsx. Поле тепер undefined, компонент рендерить порожнечу, а ваш трекер помилок починає палати о другій ночі.
Це проблема API-контракту. Це не технологічна проблема. Це проблема координації. І жодні Swagger-доки чи GraphQL-схеми не виправлять того факту, що типи вашого фронтенду й бекенду можуть непомітно розходитися.
tRPC вирішує це, просто не дозволяючи їм розходитися. Немає файлу схеми. Немає етапу кодогенерації. Немає окремого контракту для підтримки. Ви пишете TypeScript-функцію на сервері, і клієнт знає точні типи вхідних і вихідних даних під час компіляції. Якщо ви перейменуєте поле, фронтенд не скомпілюється, поки ви це не виправите.
Така обіцянка. Дозвольте показати, як це насправді працює, де tRPC сяє, і де його абсолютно не варто використовувати.
Проблема, якщо точніше#
Давайте розглянемо, як більшість команд будує API сьогодні.
REST + OpenAPI: Ви пишете свої ендпоінти. Можливо, додаєте Swagger-анотації. Можливо, генеруєте клієнтський SDK з OpenAPI-специфікації. Але специфікація — це окремий артефакт. Вона може застаріти. Етап генерації — це ще одна річ у вашому CI-пайплайні, яка може зламатися або бути забутою. І згенеровані типи часто жахливі — глибоко вкладені монстри типу paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"].
GraphQL: Краща типобезпечність, але величезна церемонійність. Ви пишете схему в SDL. Пишете резолвери. Генеруєте типи зі схеми. Пишете запити на клієнті. Генеруєте типи із запитів. Це щонайменше два етапи кодогенерації, файл схеми і крок збірки, про який всі мають пам'ятати. Для команди, яка вже контролює і фронтенд, і бекенд, це занадто багато інфраструктури для проблеми, що має простіше рішення.
Ручні fetch-виклики: Найпоширеніший підхід і найнебезпечніший. Ви пишете fetch("/api/users"), приводите результат до User[] і сподіваєтесь на краще. Немає жодної безпеки на етапі компіляції. Приведення типу — це брехня, яку ви кажете TypeScript.
// Брехня, яку казав кожен фронтенд-розробник
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 сподіваємось, що це правильноtRPC використовує принципово інший підхід. Замість описування API в окремому форматі та генерації типів, ви пишете звичайні TypeScript-функції на сервері та імпортуєте їхні типи безпосередньо в клієнт. Без етапу генерації. Без файлу схеми. Без розбіжностей.
Основні концепції#
Перш ніж щось налаштовувати, давайте зрозуміємо ментальну модель.
Router (маршрутизатор)#
tRPC router — це колекція процедур, згрупованих разом. Думайте про нього як про контролер у MVC, тільки це просто звичайний об'єкт з вбудованим виведенням типів.
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;Цей експорт типу AppRouter — і є весь магічний трюк. Клієнт імпортує цей тип — не рантайм-код, а лише тип — і отримує повне автодоповнення та перевірку типів для кожної процедури.
Procedure (процедура)#
Процедура — це один ендпоінт. Є три види:
- Query: Операції читання. Відповідає семантиці HTTP GET. Кешується TanStack Query.
- Mutation: Операції запису. Відповідає HTTP POST. Не кешується.
- Subscription: Потоки реального часу. Використовує WebSocket.
Context (контекст)#
Контекст — це дані в межах запиту, доступні кожній процедурі. Підключення до бази даних, автентифікований користувач, заголовки запиту — все, що ви б поклали в об'єкт req Express, потрапляє сюди.
Middleware (проміжне ПЗ)#
Middleware трансформує контекст або обмежує доступ. Найпоширеніший патерн — це auth middleware, що перевіряє дійсність сесії та додає ctx.user.
Ланцюжок виведення типів#
Це критична ментальна модель. Коли ви визначаєте процедуру ось так:
t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
// input типізований як { id: string }
return db.user.findUnique({ where: { id: input.id } });
});Тип повернення проходить увесь шлях до клієнта. Якщо db.user.findUnique повертає User | null, хук useQuery клієнта матиме data з типом User | null. Ніякого ручного типізування. Ніякого приведення. Все виводиться наскрізно.
Налаштування з Next.js App Router#
Давайте побудуємо це з нуля. Я припускаю, що у вас є проєкт Next.js 14+ з App Router.
Встановлення залежностей#
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodКрок 1: Ініціалізація tRPC на сервері#
Створіть ваш екземпляр tRPC та визначте тип контексту.
// 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;Кілька речей, на які варто звернути увагу:
- Трансформер
superjson: tRPC за замовчуванням серіалізує дані як JSON, що означає, що об'єктиDate,Map,Setта інші не-JSON типи втрачаються. superjson їх зберігає. - Форматувальник помилок: Ми додаємо помилки Zod-валідації до відповіді, щоб клієнт міг показувати помилки на рівні полів.
createTRPCContext: Ця функція виконується при кожному запиті. Саме тут ви розбираєте сесію, налаштовуєте з'єднання з базою даних і будуєте об'єкт контексту.
Крок 2: Визначення маршрутизатора#
// 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;Крок 3: Відкриття через Next.js Route Handler#
В App Router tRPC працює як стандартний Route Handler. Без кастомного сервера, без спеціального плагіна Next.js.
// 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 };Ось і все. Обробляються і GET, і POST. Запити (queries) йдуть через GET (з URL-кодованим вхідом), мутації йдуть через POST.
Крок 4: Налаштування клієнта#
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Зверніть увагу: ми імпортуємо AppRouter лише як тип. Жоден серверний код не потрапляє в клієнтський бандл.
Крок 5: Налаштування Provider#
// 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 (відповідна частина)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Крок 6: Використання в компонентах#
// 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>
);
}Це user.name повністю типізовано. Якщо ви допустите друкарську помилку й напишете user.nme, TypeScript це відразу спіймає. Якщо ви зміните сервер, щоб він повертав displayName замість name, кожне використання на клієнті покаже помилку компіляції. Ніяких несподіванок під час виконання.
Контекст і Middleware#
Контекст і middleware — це те, що перетворює tRPC з "гарного трюку з типами" на "фреймворк, готовий для продакшену."
Створення контексту#
Функція контексту виконується при кожному запиті. Ось реальна версія:
// 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#
Найпоширеніший патерн middleware — це розділення публічних і захищених процедур:
// 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: {
// Перевизначаємо тип контексту — user більше не nullable
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);Після виконання цього middleware, ctx.user у будь-якій protectedProcedure гарантовано не є null. Система типів це забезпечує. Ви не можете випадково звернутися до ctx.user.id у публічній процедурі без того, щоб TypeScript не поскаржився.
Middleware на основі ролей#
Ви можете комбінувати middleware для більш детального контролю доступу:
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 для логування#
Middleware використовується не лише для автентифікації. Ось middleware для логування продуктивності:
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;
});
// Застосовуємо до всіх процедур
export const publicProcedure = t.procedure.use(loggerMiddleware);Middleware для обмеження частоти запитів#
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();
});Валідація вхідних даних з Zod#
tRPC використовує Zod для валідації вхідних даних. Це не необов'язкове оформлення — це механізм, який забезпечує безпечність вхідних даних як на клієнті, так і на сервері.
Базова валідація#
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 повністю типізований:
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});Трюк з подвійною валідацією#
Ось дещо неочевидне: валідація Zod виконується на обох сторонах. На клієнті tRPC валідує вхідні дані перед відправкою запиту. Якщо вхідні дані невалідні, запит ніколи не покине браузер. На сервері та сама схема валідує знову як захисний захід.
Це означає, що ви отримуєте миттєву клієнтську валідацію безкоштовно:
"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 приходять структуровано
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 ? "Створення..." : "Створити пост"}
</button>
</form>
);
}Складні патерни вхідних даних#
// Дискримінантні об'єднання
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(),
}),
]);
// Вхідні дані пагінації для повторного використання між процедурами
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,
};
}),
});Необов'язкові вхідні дані#
Не кожна процедура потребує вхідних даних. Запити часто обходяться без них:
const statsRouter = router({
// Вхідні дані не потрібні
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 };
}),
// Необов'язкові фільтри
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,
});
}),
});Обробка помилок#
Обробка помилок у tRPC структурована, типобезпечна та чисто інтегрується як з HTTP-семантикою, так і з клієнтським 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: "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 };
}),
});Коди помилок tRPC відповідають HTTP-статусам:
| Код tRPC | HTTP-статус | Коли використовувати |
|---|---|---|
BAD_REQUEST | 400 | Невалідний ввід поза Zod-валідацією |
UNAUTHORIZED | 401 | Не автентифікований |
FORBIDDEN | 403 | Автентифікований, але недостатні права |
NOT_FOUND | 404 | Ресурс не існує |
CONFLICT | 409 | Дублікат ресурсу |
TOO_MANY_REQUESTS | 429 | Перевищено ліміт запитів |
INTERNAL_SERVER_ERROR | 500 | Несподівана помилка сервера |
Користувацьке форматування помилок#
Пам'ятаєте форматувальник помилок з нашого налаштування? Ось як він працює на практиці:
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,
// Додаємо користувацькі поля
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("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 ? "Видалення..." : "Видалити"}
</button>
);
}Глобальна обробка помилок#
Ви можете налаштувати глобальний обробник помилок, який перехоплює всі необроблені помилки tRPC:
// У вашому TRPCProvider
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Глобальний запасний варіант для необроблених помилок мутацій
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// Перенаправлення на сторінку логіну
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);Мутації та оптимістичні оновлення#
Мутації — це місце, де tRPC дуже добре інтегрується з TanStack Query. Розглянемо реальний патерн: кнопка вподобання з оптимістичними оновленнями.
Базова мутація#
// Сервер
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 };
}),
});Оптимістичні оновлення#
Користувач натискає "Вподобати". Ви не хочете чекати 200мс на відповідь сервера, щоб оновити UI. Оптимістичні оновлення вирішують це: оновлюємо UI миттєво, а потім відкочуємо, якщо сервер відхилить.
"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 }) => {
// Скасовуємо вихідні рефетчі, щоб вони не перезаписали оптимістичне оновлення
await utils.post.byId.cancel({ id: postId });
// Зберігаємо попереднє значення
const previousPost = utils.post.byId.getData({ id: postId });
// Оптимістично оновлюємо кеш
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 { previousPost };
},
onError: (_error, { postId }, context) => {
// Відкочуємо до попереднього значення при помилці
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// Завжди рефетчимо після помилки або успіху для забезпечення стану сервера
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
♥ {initialCount}
</button>
);
}Патерн завжди однаковий:
onMutate: Скасовуємо запити, зберігаємо поточні дані, застосовуємо оптимістичне оновлення, повертаємо знімок.onError: Відкочуємо, використовуючи знімок.onSettled: Інвалідуємо запит, щоб він зробив рефетч з сервера, незалежно від успіху чи помилки.
Цей тристадійний танець гарантує, що UI завжди реагує швидко й зрештою стає узгодженим із сервером.
Інвалідація пов'язаних запитів#
Після мутації вам часто потрібно оновити пов'язані дані. useUtils() у tRPC робить це ергономічним:
const createComment = trpc.comment.create.useMutation({
onSuccess: (_data, variables) => {
// Інвалідуємо список коментарів поста
utils.comment.listByPost.invalidate({ postId: variables.postId });
// Інвалідуємо сам пост (кількість коментарів змінилася)
utils.post.byId.invalidate({ id: variables.postId });
// Інвалідуємо ВСІ списки постів (кількість коментарів у перегляді списків)
utils.post.list.invalidate();
},
});Батчинг та підписки#
HTTP-батчинг#
За замовчуванням tRPC з httpBatchLink об'єднує кілька одночасних запитів в один HTTP-виклик. Якщо компонент рендериться і запускає три запити:
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}Ці три запити автоматично об'єднуються в один HTTP-запит: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
Сервер обробляє всі три, повертає всі три результати в одній відповіді, і TanStack Query розподіляє результати по кожному хуку. Налаштування не потрібне.
Ви можете вимкнути батчинг для конкретних викликів, якщо потрібно:
// У вашому провайдері використовуйте splitLink для маршрутизації конкретних процедур по-іншому
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-підписки#
Для функцій реального часу tRPC підтримує підписки через WebSocket. Це вимагає окремого WebSocket-сервера (Next.js не підтримує WebSocket нативно в Route Handlers).
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
// In-memory event emitter (використовуйте 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);
};
});
}),
// Мутація, що тригерить сповіщення
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;
}),
});На клієнті:
"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>
{/* UI списку сповіщень */}
</div>
);
}Для WebSocket-транспорту потрібен окремий серверний процес. Ось мінімальне налаштування з бібліотекою ws:
// ws-server.ts (окремий процес)
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");А клієнту потрібен wsLink для підписок:
import { wsLink, createWSClient } from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
// Використовуємо splitLink для маршрутизації підписок через 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 не обробляє завантаження файлів нативно. Це JSON-RPC протокол — бінарні дані не є його сильною стороною. Але ви можете побудувати типобезпечний потік завантаження, поєднавши tRPC з presigned URL.
Патерн:
- Клієнт запитує у tRPC presigned URL для завантаження.
- tRPC валідує запит, перевіряє дозволи, генерує URL.
- Клієнт завантажує безпосередньо в S3, використовуючи presigned URL.
- Клієнт повідомляє tRPC, що завантаження завершено.
// 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 хвилин
});
// Зберігаємо очікуване завантаження в базі даних
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" });
}
// Перевіряємо, що файл дійсно існує в S3
// (необов'язково, але рекомендовано)
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}`,
};
}),
});На клієнті:
"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 {
// Крок 1: Отримуємо presigned URL від tRPC
const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type,
size: file.size,
});
// Крок 2: Завантажуємо безпосередньо в S3
const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Крок 3: Підтверджуємо завантаження через tRPC
const { url } = await confirmUpload.mutateAsync({ uploadId });
toast.success(`Файл завантажено: ${url}`);
} catch (error) {
toast.error("Завантаження не вдалося. Спробуйте ще раз.");
} finally {
setUploading(false);
}
},
[getPresignedUrl, confirmUpload]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>Завантаження...</p>}
</div>
);
}Весь потік типобезпечний. Тип відповіді presigned URL, тип ID завантаження, відповідь підтвердження — все виводиться з серверних визначень. Якщо ви додасте нове поле до відповіді presigned URL, клієнт дізнається про це негайно.
Серверні виклики та React Server Components#
З Next.js App Router ви часто хочете отримувати дані в серверних компонентах. tRPC підтримує це через серверні виклики (callers):
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// Використання в серверному компоненті
// 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>
{/* Клієнтські компоненти для інтерактивних частин */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}Це дає вам найкраще з обох світів: серверно-рендерені початкові дані з повною типобезпечністю та клієнтську інтерактивність для мутацій і функцій реального часу.
Тестування процедур tRPC#
Тестування просте, тому що процедури — це просто функції. Вам не потрібно піднімати HTTP-сервер.
// 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: "", // мінімальна довжина 1
})
).rejects.toThrow();
});
});Ніякого мокування HTTP-шарів, ніякого supertest, ніякого зіставлення маршрутів. Просто викликаєте функцію і перевіряєте результат. Це одна з недооцінених переваг tRPC: тестування тривіально просте, бо транспортний шар — це деталь реалізації.
Коли НЕ варто використовувати tRPC#
tRPC — це не універсальне рішення. Ось де він не підходить:
Публічні API#
Якщо ви будуєте API, який будуть використовувати зовнішні розробники, tRPC — неправильний вибір. Зовнішні споживачі не мають доступу до ваших TypeScript-типів. Їм потрібен задокументований, стабільний контракт — OpenAPI/Swagger для REST або GraphQL-схема. Типобезпечність tRPC працює лише тоді, коли і клієнт, і сервер працюють в одній TypeScript-кодовій базі.
Мобільні додатки (якщо не на TypeScript)#
Якщо ваш мобільний додаток написаний на Swift, Kotlin або Dart, tRPC нічого не пропонує. Типи не перетинають межі мов програмування. Ви теоретично можете згенерувати OpenAPI-специфікацію з tRPC-маршрутів за допомогою trpc-openapi, але на цьому етапі ви повертаєте церемонійність назад. Просто використовуйте REST з самого початку.
Мікросервіси#
tRPC припускає одну TypeScript-кодову базу. Якщо ваш бекенд розділений на кілька сервісів на різних мовах, tRPC не може допомогти з міжсервісною комунікацією. Використовуйте gRPC, REST або черги повідомлень для цього.
Великі команди з окремими репозиторіями фронтенду/бекенду#
Якщо ваш фронтенд і бекенд живуть в окремих репозиторіях з окремими пайплайнами деплою, ви втрачаєте головну перевагу tRPC. Спільне використання типів вимагає монорепо або спільного пакету. Ви можете опублікувати тип AppRouter як npm-пакет, але тоді у вас з'являється проблема версіонування, яку REST + OpenAPI вирішує більш природно.
Коли потрібна REST-семантика#
Якщо вам потрібні HTTP-заголовки кешування, узгодження контенту, ETags або інші REST-специфічні можливості, абстракція tRPC над HTTP буде заважати. tRPC розглядає HTTP як деталь транспорту, а не як функціональність.
Фреймворк для прийняття рішень#
Ось як я приймаю рішення:
| Сценарій | Рекомендація |
|---|---|
| Повностекова TypeScript-програма в одному репо | tRPC — максимальна користь, мінімальні накладні витрати |
| Внутрішній інструмент / адмін-панель | tRPC — швидкість розробки є пріоритетом |
| Публічний API для сторонніх розробників | REST + OpenAPI — споживачам потрібна документація, а не типи |
| Мобільний + веб-клієнти (не-TS мобільний) | REST або GraphQL — потрібні мовно-агностичні контракти |
| Інтенсивний реальний час (чат, ігри) | tRPC subscriptions або raw WebSockets залежно від складності |
| Окремі команди фронтенду/бекенду | GraphQL — схема є контрактом між командами |
Практичні поради з продакшену#
Кілька речей, які я дізнався, запускаючи tRPC на продакшені, яких немає в документації:
Тримайте маршрутизатори маленькими. Один файл маршрутизатора не повинен перевищувати 200 рядків. Розділяйте за доменом: userRouter, postRouter, billingRouter. Кожен в окремому файлі.
Використовуйте createCallerFactory для серверних викликів. Не тягніться до fetch, коли викликаєте свій власний API з серверного компонента. Caller factory дає вам таку саму типобезпечність з нульовими HTTP-накладними витратами.
Не переоптимізуйте батчинг. Стандартного httpBatchLink майже завжди достатньо. Я бачив команди, які витрачали дні на налаштування конфігурацій splitLink заради незначних покращень. Спочатку профілюйте.
Встановіть staleTime в QueryClient. За замовчуванням staleTime дорівнює 0, що означає, що кожна подія фокусу запускає рефетч. Встановіть щось розумне (від 30 секунд до 5 хвилин) залежно від вимог до свіжості даних.
Використовуйте superjson з першого дня. Додавання його пізніше означає одночасну міграцію кожного клієнта і сервера. Це одна стрічка конфігурації, яка рятує вас від багів серіалізації Date.
Error boundaries — ваші друзі. Оберніть розділи сторінок, які активно використовують tRPC, в React error boundaries. Один невдалий запит не повинен знищити всю сторінку.
"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">Щось пішло не так</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"
>
Спробувати ще раз
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}Підсумки#
tRPC — це не заміна REST чи GraphQL. Це інший інструмент для конкретної ситуації: коли ви контролюєте і клієнт, і сервер, обидва написані на TypeScript, і ви хочете найкоротший можливий шлях від "я змінив бекенд" до "фронтенд знає про це."
У цій ситуації ніщо інше не зрівняється. Без кодогенерації, без файлів схем, без розбіжностей. Просто TypeScript робить те, що він робить найкраще: ловить помилки до того, як вони потраплять на продакшен.
Компроміс очевидний: ви відмовляєтесь від інтероперабельності на рівні протоколу (жодних не-TypeScript клієнтів) в обмін на швидкість розробки та безпеку на етапі компіляції, якої складно досягти іншим способом.
Для більшості повностекових TypeScript-додатків це компроміс, який варто прийняти.