Как tRPC устраняет проблему контракта API, работает с Next.js App Router, обрабатывает мидлвар аутентификации, загрузку файлов и подписки. Реальная настройка tRPC с нуля.
Вы знаете эту историю. Вы меняете имя поля в ответе API. Обновляете тип на бэкенде. Деплоите. А потом фронтенд ломается в продакшне, потому что кто-то забыл обновить fetch-вызов на строке 247 файла Dashboard.tsx. Теперь поле undefined, компонент рендерит пустоту, а ваша система отслеживания ошибок загорается в 2 часа ночи.
Это проблема контракта API. Это не технологическая проблема. Это проблема координации. И никакие Swagger-документы или GraphQL-схемы не исправят того факта, что типы вашего фронтенда и бэкенда могут незаметно рассинхронизироваться.
tRPC решает это, просто не давая им рассинхронизироваться. Нет файла схемы. Нет этапа кодогенерации. Нет отдельного контракта для поддержки. Вы пишете TypeScript-функцию на сервере, и клиент знает её точные типы ввода и вывода во время компиляции. Если вы переименуете поле, фронтенд не скомпилируется, пока вы это не исправите.
Таково обещание. Позвольте показать, как это работает на самом деле, где это блистает и где вам однозначно не стоит это использовать.
Давайте посмотрим, как большинство команд строят 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-функции на сервере и импортируете их типы напрямую в клиент. Никакого этапа генерации. Никакого файла схемы. Никакой рассинхронизации.
Прежде чем что-либо настраивать, давайте поймём ментальную модель.
Роутер tRPC — это коллекция процедур, сгруппированных вместе. Думайте о нём как о контроллере в 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 — вот и весь фокус. Клиент импортирует этот тип — не рантайм-код, только тип — и получает полное автодополнение и проверку типов для каждой процедуры.
Процедура — это одна конечная точка. Существует три вида:
Контекст — это данные, привязанные к запросу, доступные каждой процедуре. Подключения к базе данных, аутентифицированный пользователь, заголовки запроса — всё, что вы бы поместили в объект req Express, идёт сюда.
Мидлвар трансформирует контекст или ограничивает доступ. Самый распространённый паттерн — мидлвар аутентификации, который проверяет наличие валидной сессии и добавляет 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 14+ с App Router.
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodСоздайте экземпляр 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 их сохраняет.createTRPCContext: Эта функция выполняется при каждом запросе. Именно здесь вы парсите сессию, настраиваете подключение к базе данных и формируете объект контекста.// 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;В 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. Запросы идут через GET (с URL-кодированным вводом), мутации — через POST.
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Обратите внимание: мы импортируем AppRouter только как тип. Никакой серверный код не попадает в клиентский бандл.
// 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>
);
}// 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>С возвращением, {user.name}</h1>
<p>Участник с {user.createdAt.toLocaleDateString()}</p>
</div>
);
}Этот user.name полностью типизирован. Если вы допустите опечатку user.nme, TypeScript сразу это поймает. Если вы измените сервер, чтобы он возвращал displayName вместо name, все использования на клиенте покажут ошибку компиляции. Никаких сюрпризов в рантайме.
Контекст и мидлвар — это то, что превращает 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>>;Самый распространённый паттерн мидлвара — разделение публичных и защищённых процедур:
// 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);После выполнения этого мидлвара ctx.user в любой protectedProcedure гарантированно не null. Система типов это обеспечивает. Вы не сможете случайно обратиться к ctx.user.id в публичной процедуре без того, чтобы TypeScript пожаловался.
Можно композировать мидлвары для более гранулированного контроля доступа:
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (ctx.user.role !== "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}
return next({
ctx: {
user: ctx.user,
},
});
});
export const adminProcedure = t.procedure.use(isAdmin);Мидлвар используется не только для аутентификации. Вот мидлвар для логирования производительности:
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(`Медленный ${type} ${path}: ${duration}мс`);
}
return result;
});
// Применяем ко всем процедурам
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: `Rate limit exceeded. Try again later.`,
});
}
return next();
});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("Пост удалён");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("У вас нет прав на удаление этого поста");
break;
case "NOT_FOUND":
toast.error("Этот пост больше не существует");
utils.post.list.invalidate();
break;
default:
toast.error("Что-то пошло не так. Попробуйте снова.");
}
},
});
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();
},
});По умолчанию 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,
}),
}),
],
})
);Для функций реального времени 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("Ошибка подписки:", 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(`Соединение открыто (всего ${wss.clients.size})`);
ws.once("close", () => {
console.log(`Соединение закрыто (всего ${wss.clients.size})`);
});
});
process.on("SIGTERM", () => {
handler.broadcastReconnectNotification();
wss.close();
});
console.log("WebSocket-сервер слушает на 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 с предподписанными URL.
Паттерн:
// 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: Получаем предподписанный 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>
);
}Весь процесс типобезопасен. Тип ответа предподписанного URL, тип ID загрузки, ответ подтверждения — всё выведено из серверных определений. Если вы добавите новое поле в ответ предподписанного URL, клиент узнает об этом немедленно.
С 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>
);
}Это даёт лучшее из двух миров: серверный рендеринг начальных данных с полной типобезопасностью и клиентская интерактивность для мутаций и функций реального времени.
Тестирование простое, потому что процедуры — это просто функции. Вам не нужно поднимать 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: "", // min length 1
})
).rejects.toThrow();
});
});Никакого мокирования HTTP-слоёв, никакого supertest, никакого сопоставления маршрутов. Просто вызовите функцию и проверьте результат. Это одно из недооценённых преимуществ tRPC: тестирование тривиально просто, потому что транспортный уровень — это деталь реализации.
tRPC — не универсальное решение. Вот где он не работает:
Если вы создаёте API, которое будут потреблять внешние разработчики, tRPC — неправильный выбор. Внешние потребители не имеют доступа к вашим TypeScript-типам. Им нужен документированный, стабильный контракт — OpenAPI/Swagger для REST или GraphQL-схема. Типобезопасность tRPC работает только когда и клиент, и сервер разделяют одну TypeScript-кодовую базу.
Если ваше мобильное приложение написано на Swift, Kotlin или Dart, tRPC ничего не предлагает. Типы не пересекают границы языков. Теоретически можно сгенерировать OpenAPI-спецификацию из маршрутов tRPC, используя trpc-openapi, но в этот момент вы добавляете церемонию обратно. Просто используйте REST с самого начала.
tRPC предполагает единую TypeScript-кодовую базу. Если ваш бэкенд разделён между несколькими сервисами на разных языках, tRPC не поможет с межсервисной коммуникацией. Используйте gRPC, REST или очереди сообщений для этого.
Если ваш фронтенд и бэкенд живут в отдельных репозиториях с отдельными пайплайнами деплоя, вы теряете главное преимущество tRPC. Обмен типами требует монорепозитория или общего пакета. Вы можете опубликовать тип AppRouter как npm-пакет, но теперь у вас проблема версионирования, которую REST + OpenAPI решает более естественно.
Если вам нужны HTTP-кеширующие заголовки, согласование контента, ETag или другие REST-специфичные функции, абстракция tRPC над HTTP будет с вами бороться. tRPC рассматривает HTTP как деталь транспорта, а не как функцию.
Вот как я решаю:
| Сценарий | Рекомендация |
|---|---|
| Fullstack TypeScript-приложение в одном репо | tRPC — максимальная выгода, минимум накладных расходов |
| Внутренний инструмент / админ-панель | tRPC — скорость разработки в приоритете |
| Публичное API для сторонних разработчиков | REST + OpenAPI — потребителям нужна документация, а не типы |
| Мобильный + веб клиенты (не-TS мобильный) | REST или GraphQL — нужны языконезависимые контракты |
| Много реального времени (чат, игры) | tRPC подписки или чистые WebSocket в зависимости от сложности |
| Раздельные команды фронтенда и бэкенда | GraphQL — схема является контрактом между командами |
Несколько вещей, которые я узнал, эксплуатируя tRPC в продакшне, и которых нет в документации:
Держите роутеры маленькими. Один файл роутера не должен превышать 200 строк. Разделяйте по доменам: userRouter, postRouter, billingRouter. Каждый в своём файле.
Используйте createCallerFactory для серверных вызовов. Не тянитесь к fetch, когда вызываете своё собственное API из серверного компонента. Фабрика вызывающих объектов даёт ту же типобезопасность без 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>Панель управления</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}tRPC — это не замена REST или GraphQL. Это другой инструмент для конкретной ситуации: когда вы контролируете и клиент, и сервер, оба на TypeScript, и вы хотите кратчайший путь от «я изменил бэкенд» до «фронтенд об этом знает».
В этой ситуации ничто другое не приблизится. Никакой кодогенерации, никаких файлов схем, никакой рассинхронизации. Просто TypeScript делает то, что он делает лучше всего: ловит ошибки до того, как они попадут в продакшн.
Компромисс ясен: вы отказываетесь от совместимости на уровне протокола (нет не-TypeScript клиентов) в обмен на скорость разработки и безопасность во время компиляции, которую трудно достичь иным способом.
Для большинства fullstack TypeScript-приложений это компромисс, на который стоит пойти.