Перейти к содержимому
·24 мин чтения

tRPC: сквозная типобезопасность без лишней церемонии

Как tRPC устраняет проблему контракта API, работает с Next.js App Router, обрабатывает мидлвар аутентификации, загрузку файлов и подписки. Реальная настройка tRPC с нуля.

Поделиться:X / TwitterLinkedIn

Вы знаете эту историю. Вы меняете имя поля в ответе 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.

typescript
// Ложь, которую каждый фронтенд-разработчик когда-то говорил
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // надеемся, что это правильно

tRPC использует совершенно другой подход. Вместо того чтобы описывать ваше API в отдельном формате и генерировать типы, вы пишете обычные TypeScript-функции на сервере и импортируете их типы напрямую в клиент. Никакого этапа генерации. Никакого файла схемы. Никакой рассинхронизации.

Основные концепции#

Прежде чем что-либо настраивать, давайте поймём ментальную модель.

Роутер#

Роутер tRPC — это коллекция процедур, сгруппированных вместе. Думайте о нём как о контроллере в MVC, только это просто обычный объект со встроенным выводом типов.

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

Этот экспорт типа AppRouter — вот и весь фокус. Клиент импортирует этот тип — не рантайм-код, только тип — и получает полное автодополнение и проверку типов для каждой процедуры.

Процедура#

Процедура — это одна конечная точка. Существует три вида:

  • Query: Операции чтения. Соответствует HTTP GET семантике. Кешируется TanStack Query.
  • Mutation: Операции записи. Соответствует HTTP POST. Не кешируется.
  • Subscription: Потоки в реальном времени. Использует WebSocket.

Контекст#

Контекст — это данные, привязанные к запросу, доступные каждой процедуре. Подключения к базе данных, аутентифицированный пользователь, заголовки запроса — всё, что вы бы поместили в объект req Express, идёт сюда.

Мидлвар#

Мидлвар трансформирует контекст или ограничивает доступ. Самый распространённый паттерн — мидлвар аутентификации, который проверяет наличие валидной сессии и добавляет ctx.user.

Цепочка вывода типов#

Это ключевая ментальная модель. Когда вы определяете процедуру так:

typescript
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.

Установка зависимостей#

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

Шаг 1: Инициализация tRPC на сервере#

Создайте экземпляр tRPC и определите тип контекста.

typescript
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import superjson from "superjson";
import { ZodError } from "zod";
 
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const session = await getSession(opts.req);
 
  return {
    session,
    db: prisma,
    req: opts.req,
  };
}
 
const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});
 
export const createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;

Несколько вещей, на которые стоит обратить внимание:

  • Трансформер superjson: tRPC по умолчанию сериализует данные как JSON, что означает, что объекты Date, Map, Set и другие не-JSON типы теряются. superjson их сохраняет.
  • Форматировщик ошибок: Мы прикрепляем ошибки валидации Zod к ответу, чтобы клиент мог показывать ошибки по полям.
  • createTRPCContext: Эта функция выполняется при каждом запросе. Именно здесь вы парсите сессию, настраиваете подключение к базе данных и формируете объект контекста.

Шаг 2: Определите роутер#

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

Шаг 3: Экспонирование через Route Handler Next.js#

В App Router tRPC работает как стандартный Route Handler. Никакого кастомного сервера, никакого специального плагина Next.js.

typescript
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
 
const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: createTRPCContext,
    onError:
      process.env.NODE_ENV === "development"
        ? ({ path, error }) => {
            console.error(
              `tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
            );
          }
        : undefined,
  });
 
export { handler as GET, handler as POST };

Вот и всё. Обрабатываются и GET, и POST. Запросы идут через GET (с URL-кодированным вводом), мутации — через POST.

Шаг 4: Настройка клиента#

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

Обратите внимание: мы импортируем AppRouter только как тип. Никакой серверный код не попадает в клиентский бандл.

Шаг 5: Настройка провайдера#

typescript
// src/components/providers/TRPCProvider.tsx
"use client";
 
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
 
function getBaseUrl() {
  if (typeof window !== "undefined") return "";
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return `http://localhost:${process.env.PORT ?? 3000}`;
}
 
export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000,
            retry: 1,
          },
        },
      })
  );
 
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      ],
    })
  );
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
typescript
// src/app/layout.tsx (соответствующая часть)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

Шаг 6: Использование в компонентах#

typescript
// src/app/dashboard/page.tsx
"use client";
 
import { trpc } from "@/lib/trpc";
 
export default function DashboardPage() {
  const { data: user, isLoading, error } = trpc.user.me.useQuery();
 
  if (isLoading) return <DashboardSkeleton />;
  if (error) return <ErrorDisplay message={error.message} />;
 
  return (
    <div>
      <h1>С возвращением, {user.name}</h1>
      <p>Участник с {user.createdAt.toLocaleDateString()}</p>
    </div>
  );
}

Этот user.name полностью типизирован. Если вы допустите опечатку user.nme, TypeScript сразу это поймает. Если вы измените сервер, чтобы он возвращал displayName вместо name, все использования на клиенте покажут ошибку компиляции. Никаких сюрпризов в рантайме.

Контекст и мидлвар#

Контекст и мидлвар — это то, что превращает tRPC из «красивого трюка с типами» в «фреймворк, готовый к продакшну».

Создание контекста#

Функция контекста выполняется при каждом запросе. Вот реальная версия:

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

Мидлвар аутентификации#

Самый распространённый паттерн мидлвара — разделение публичных и защищённых процедур:

typescript
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to perform this action",
    });
  }
 
  return next({
    ctx: {
      // Переопределяем тип контекста — user больше не nullable
      user: ctx.user,
    },
  });
});
 
export const protectedProcedure = t.procedure.use(isAuthed);

После выполнения этого мидлвара ctx.user в любой protectedProcedure гарантированно не null. Система типов это обеспечивает. Вы не сможете случайно обратиться к ctx.user.id в публичной процедуре без того, чтобы TypeScript пожаловался.

Мидлвар на основе ролей#

Можно композировать мидлвары для более гранулированного контроля доступа:

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

Мидлвар логирования#

Мидлвар используется не только для аутентификации. Вот мидлвар для логирования производительности:

typescript
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
 
  const result = await next();
 
  const duration = Date.now() - start;
 
  if (duration > 1000) {
    console.warn(`Медленный ${type} ${path}: ${duration}мс`);
  }
 
  return result;
});
 
// Применяем ко всем процедурам
export const publicProcedure = t.procedure.use(loggerMiddleware);

Мидлвар ограничения частоты запросов#

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

Валидация ввода с помощью Zod#

tRPC использует Zod для валидации ввода. Это не опциональное украшение — это механизм, обеспечивающий безопасность ввода как на клиенте, так и на сервере.

Базовая валидация#

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

Трюк с двойной валидацией#

Вот тонкий момент: валидация Zod выполняется на обеих сторонах. На клиенте tRPC валидирует ввод перед отправкой запроса. Если ввод невалиден, запрос даже не покидает браузер. На сервере та же схема валидирует повторно как мера безопасности.

Это значит, что вы получаете мгновенную клиентскую валидацию бесплатно:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function CreatePostForm() {
  const [title, setTitle] = useState("");
  const utils = trpc.useUtils();
 
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      utils.post.list.invalidate();
    },
    onError: (error) => {
      // Ошибки Zod приходят структурированными
      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>
  );
}

Сложные паттерны ввода#

typescript
// Дискриминированные объединения
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,
      };
    }),
});

Опциональные входные данные#

Не каждая процедура нуждается во входных данных. Запросы часто обходятся без них:

typescript
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.

Генерация ошибок на сервере#

typescript
import { TRPCError } from "@trpc/server";
 
const postRouter = router({
  delete: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
      });
 
      if (!post) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Post not found",
        });
      }
 
      if (post.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "You can only delete your own posts",
        });
      }
 
      await ctx.db.post.delete({ where: { id: input.id } });
 
      return { success: true };
    }),
});

Коды ошибок tRPC соответствуют HTTP-статусам:

Код tRPCHTTP-статусКогда использовать
BAD_REQUEST400Невалидный ввод за пределами Zod-валидации
UNAUTHORIZED401Не авторизован
FORBIDDEN403Авторизован, но недостаточно прав
NOT_FOUND404Ресурс не существует
CONFLICT409Дублирование ресурса
TOO_MANY_REQUESTS429Превышен лимит запросов
INTERNAL_SERVER_ERROR500Неожиданная серверная ошибка

Кастомное форматирование ошибок#

Помните форматировщик ошибок из нашей настройки? Вот как он работает на практике:

typescript
const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
        // Добавляем кастомные поля
        timestamp: new Date().toISOString(),
        requestId: crypto.randomUUID(),
      },
    };
  },
});

Обработка ошибок на клиенте#

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
 
export function DeletePostButton({ postId }: { postId: string }) {
  const utils = trpc.useUtils();
 
  const deletePost = trpc.post.delete.useMutation({
    onSuccess: () => {
      toast.success("Пост удалён");
      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:

typescript
// В вашем 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. Давайте рассмотрим реальный паттерн: кнопка «Нравится» с оптимистичными обновлениями.

Базовая мутация#

typescript
// Сервер
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 немедленно, затем откатите, если сервер отклонит запрос.

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
 
export function LikeButton({ postId, initialLiked, initialCount }: {
  postId: string;
  initialLiked: boolean;
  initialCount: number;
}) {
  const utils = trpc.useUtils();
 
  const toggleLike = trpc.post.toggleLike.useMutation({
    onMutate: async ({ postId }) => {
      // Отменяем исходящие рефетчи, чтобы они не перезаписали наше оптимистичное обновление
      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>
  );
}

Паттерн всегда один и тот же:

  1. onMutate: Отменить запросы, сохранить снепшот текущих данных, применить оптимистичное обновление, вернуть снепшот.
  2. onError: Откатиться, используя снепшот.
  3. onSettled: Инвалидировать запрос, чтобы он рефетчился с сервера, независимо от успеха или ошибки.

Этот трёхшаговый танец гарантирует, что UI всегда отзывчив и в итоге согласован с сервером.

Инвалидация связанных запросов#

После мутации часто нужно обновить связанные данные. useUtils() в tRPC делает это эргономичным:

typescript
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-вызов. Если компонент рендерится и запускает три запроса:

typescript
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 распределяет результаты по каждому хуку. Никакой конфигурации не требуется.

Вы можете отключить батчинг для отдельных вызовов при необходимости:

typescript
// В вашем провайдере используйте 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).

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

На клиенте:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function NotificationBell() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  trpc.notification.onNew.useSubscription(undefined, {
    onData: (notification) => {
      setNotifications((prev) => [notification, ...prev]);
      toast.info(notification.message);
    },
    onError: (error) => {
      console.error("Ошибка подписки:", error);
    },
  });
 
  return (
    <div>
      <span className="badge">{notifications.length}</span>
      {/* UI списка уведомлений */}
    </div>
  );
}

Для WebSocket-транспорта вам нужен отдельный серверный процесс. Вот минимальная настройка с библиотекой ws:

typescript
// 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 для подписок:

typescript
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.

Паттерн:

  1. Клиент запрашивает у tRPC предподписанный URL для загрузки.
  2. tRPC валидирует запрос, проверяет разрешения, генерирует URL.
  3. Клиент загружает напрямую в S3, используя предподписанный URL.
  4. Клиент уведомляет tRPC о завершении загрузки.
typescript
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 
const s3 = new S3Client({ region: process.env.AWS_REGION });
 
export const uploadRouter = router({
  getPresignedUrl: protectedProcedure
    .input(
      z.object({
        filename: z.string().min(1).max(255),
        contentType: z.string().regex(/^(image|application)\//),
        size: z.number().max(10 * 1024 * 1024, "File must be under 10MB"),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
 
      const command = new PutObjectCommand({
        Bucket: process.env.S3_BUCKET!,
        Key: key,
        ContentType: input.contentType,
        ContentLength: input.size,
      });
 
      const presignedUrl = await getSignedUrl(s3, command, {
        expiresIn: 300, // 5 минут
      });
 
      // Сохраняем ожидающую загрузку в базе данных
      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}`,
      };
    }),
});

На клиенте:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
 
export function FileUpload() {
  const [uploading, setUploading] = useState(false);
 
  const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
  const confirmUpload = trpc.upload.confirmUpload.useMutation();
 
  const handleFileChange = useCallback(
    async (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;
 
      setUploading(true);
 
      try {
        // Шаг 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, клиент узнает об этом немедленно.

Серверные вызовы и React Server Components#

С Next.js App Router вы часто хотите получать данные в серверных компонентах. tRPC поддерживает это через серверные вызывающие объекты (callers):

typescript
// 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-сервер.

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

Никакого мокирования 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-кеширующие заголовки, согласование контента, 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. Один неудачный запрос не должен обрушить всю страницу.

typescript
"use client";
 
import { ErrorBoundary } from "react-error-boundary";
 
function ErrorFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert" className="p-4 bg-red-50 rounded-lg">
      <p className="font-medium text-red-800">Что-то пошло не так</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-приложений это компромисс, на который стоит пойти.

Похожие записи