Ir para o conteúdo
·27 min de leitura

tRPC: Type Safety End-to-End Sem Cerimônia

Como o tRPC elimina o problema de contrato de API, funciona com Next.js App Router, lida com middleware de auth, uploads de arquivo e subscriptions. Um setup real de tRPC do zero.

Compartilhar:X / TwitterLinkedIn

Você conhece a rotina. Você muda o nome de um campo na resposta da sua API. Atualiza o tipo no backend. Faz deploy. Aí o frontend quebra em produção porque alguém esqueceu de atualizar a chamada fetch na linha 247 do Dashboard.tsx. O campo agora é undefined, o componente renderiza em branco, e seu rastreamento de erros acende às 2 da manhã.

Esse é o problema do contrato de API. Não é um problema de tecnologia. É um problema de coordenação. E nenhuma quantidade de documentação Swagger ou schemas GraphQL vai corrigir o fato de que os tipos do seu frontend e backend podem divergir silenciosamente.

O tRPC resolve isso recusando-se a deixar divergirem. Não há arquivo de schema. Não há passo de geração de código. Não há contrato separado para manter. Você escreve uma função TypeScript no servidor, e o cliente conhece seus tipos exatos de entrada e saída em tempo de compilação. Se você renomeia um campo, o frontend não compila até você corrigir.

Essa é a promessa. Vou mostrar como funciona na prática, onde brilha, e onde você absolutamente não deveria usá-lo.

O Problema, Mais Precisamente#

Vamos ver como a maioria dos times constrói APIs hoje.

REST + OpenAPI: Você escreve seus endpoints. Talvez adicione anotações Swagger. Talvez gere um SDK cliente a partir da spec OpenAPI. Mas a spec é um artefato separado. Ela pode ficar desatualizada. O passo de geração é mais uma coisa no seu pipeline de CI que pode quebrar ou ser esquecida. E os tipos gerados são frequentemente feios — monstros profundamente aninhados paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"].

GraphQL: Melhor type safety, mas cerimônia enorme. Você escreve um schema em SDL. Escreve resolvers. Gera tipos a partir do schema. Escreve queries no cliente. Gera tipos a partir das queries. São pelo menos dois passos de geração de código, um arquivo de schema e um passo de build que todo mundo precisa lembrar de executar. Para um time que já controla tanto frontend quanto backend, é muita infraestrutura para um problema que tem uma solução mais simples.

Chamadas fetch manuais: A abordagem mais comum e a mais perigosa. Você escreve fetch("/api/users"), faz cast do resultado para User[], e torce pelo melhor. Não há zero segurança em tempo de compilação. A asserção de tipo é uma mentira que você conta ao TypeScript.

typescript
// A mentira que todo desenvolvedor frontend já contou
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 torce pra estar certo

O tRPC adota uma abordagem completamente diferente. Em vez de descrever sua API em um formato separado e gerar tipos, você escreve funções TypeScript puras no servidor e importa seus tipos diretamente no cliente. Sem passo de geração. Sem arquivo de schema. Sem divergência.

Conceitos Fundamentais#

Antes de configurar qualquer coisa, vamos entender o modelo mental.

Router#

Um router tRPC é uma coleção de procedures agrupadas. Pense nele como um controller em MVC, exceto que é apenas um objeto plain com inferência de tipos embutida.

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;

Essa exportação de tipo AppRouter é todo o truque de mágica. O cliente importa esse tipo — não o código runtime, apenas o tipo — e ganha autocompletion completo e verificação de tipos para cada procedure.

Procedure#

Uma procedure é um único endpoint. Existem três tipos:

  • Query: Operações de leitura. Mapeia para semântica HTTP GET. Cache feito pelo TanStack Query.
  • Mutation: Operações de escrita. Mapeia para HTTP POST. Sem cache.
  • Subscription: Streams em tempo real. Usa WebSockets.

Context#

Context são os dados com escopo de requisição disponíveis para toda procedure. Conexões de banco de dados, o usuário autenticado, headers da requisição — qualquer coisa que você colocaria no objeto req do Express vai aqui.

Middleware#

Middleware transforma o context ou controla acesso. O padrão mais comum é um middleware de auth que verifica uma sessão válida e adiciona ctx.user.

A Cadeia de Inferência de Tipos#

Este é o modelo mental crítico. Quando você define uma procedure assim:

typescript
t.procedure
  .input(z.object({ id: z.string() }))
  .query(({ input }) => {
    // input é tipado como { id: string }
    return db.user.findUnique({ where: { id: input.id } });
  });

O tipo de retorno flui até o cliente. Se db.user.findUnique retorna User | null, o hook useQuery do cliente terá data tipado como User | null. Sem tipagem manual. Sem casting. É inferido end-to-end.

Setup com Next.js App Router#

Vamos construir isso do zero. Vou assumir que você tem um projeto Next.js 14+ com o App Router.

Instalar Dependências#

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

Passo 1: Inicializar tRPC no Servidor#

Crie sua instância tRPC e defina o tipo de context.

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;

Algumas coisas a notar:

  • Transformer superjson: tRPC serializa dados como JSON por padrão, o que significa que objetos Date, Map, Set e outros tipos não-JSON se perdem. O superjson os preserva.
  • Formatador de erros: Anexamos erros de validação Zod à resposta para que o cliente possa mostrar erros por campo.
  • createTRPCContext: Essa função roda em cada requisição. É onde você parseia a sessão, configura a conexão com o banco de dados e constrói o objeto de context.

Passo 2: Defina Seu Router#

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

Passo 3: Expor via Next.js Route Handler#

No App Router, o tRPC roda como um Route Handler padrão. Sem servidor customizado, sem plugin especial do 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 falhou em ${path ?? "<no-path>"}: ${error.message}`
            );
          }
        : undefined,
  });
 
export { handler as GET, handler as POST };

É isso. Tanto GET quanto POST são tratados. Queries passam por GET (com input codificado na URL), mutations passam por POST.

Passo 4: Configure o Cliente#

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

Nota: importamos AppRouter apenas como tipo. Nenhum código do servidor vaza para o bundle do cliente.

Passo 5: Setup do Provider#

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

Passo 6: Use nos Componentes#

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>Bem-vindo de volta, {user.name}</h1>
      <p>Membro desde {user.createdAt.toLocaleDateString()}</p>
    </div>
  );
}

Aquele user.name é totalmente tipado. Se você digitar errado como user.nme, o TypeScript pega imediatamente. Se você mudar o servidor para retornar displayName em vez de name, cada uso no cliente mostrará um erro de compilação. Sem surpresas em runtime.

Context e Middleware#

Context e middleware são onde o tRPC vai de "truque de tipo legal" para "framework pronto para produção."

Criando Context#

A função de context roda em cada requisição. Aqui está uma versão do mundo real:

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

Middleware de Auth#

O padrão de middleware mais comum é separar procedures públicas de protegidas:

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: {
      // Sobrescreve o tipo do context — user não é mais nullable
      user: ctx.user,
    },
  });
});
 
export const protectedProcedure = t.procedure.use(isAuthed);

Depois que este middleware roda, ctx.user em qualquer protectedProcedure é garantidamente não-nulo. O sistema de tipos impõe isso. Você não pode acessar ctx.user.id acidentalmente em uma procedure pública sem o TypeScript reclamar.

Middleware Baseado em Papéis#

Você pode compor middleware para controle de acesso mais granular:

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

Middleware de Logging#

Middleware não é só para auth. Aqui está um middleware de logging de performance:

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} lento ${path}: ${duration}ms`);
  }
 
  return result;
});
 
// Aplica a todas as procedures
export const publicProcedure = t.procedure.use(loggerMiddleware);

Middleware de Rate Limiting#

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

Validação de Input com Zod#

O tRPC usa Zod para validação de input. Isso não é decoração opcional — é o mecanismo que garante que os inputs sejam seguros tanto no cliente quanto no servidor.

Validação Básica#

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 é totalmente tipado:
      // {
      //   title: string;
      //   content: string;
      //   categoryId: string;
      //   tags: string[];
      //   published: boolean;
      // }
 
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),
});

O Truque da Validação Dupla#

Aqui está algo sutil: a validação Zod roda em ambos os lados. No cliente, o tRPC valida o input antes de enviar a requisição. Se o input é inválido, a requisição nunca sai do navegador. No servidor, o mesmo schema valida novamente como medida de segurança.

Isso significa que você ganha validação client-side instantânea de graça:

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) => {
      // Erros Zod chegam estruturados
      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 ? "Criando..." : "Criar Post"}
      </button>
    </form>
  );
}

Padrões Complexos de Input#

typescript
// Uniões discriminadas
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(),
  }),
]);
 
// Input de paginação reutilizado entre procedures
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,
      };
    }),
});

Inputs Opcionais#

Nem toda procedure precisa de input. Queries frequentemente não precisam:

typescript
const statsRouter = router({
  // Sem input necessário
  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 };
  }),
 
  // Filtros opcionais
  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,
      });
    }),
});

Tratamento de Erros#

O tratamento de erros do tRPC é estruturado, type-safe e se integra de forma limpa tanto com a semântica HTTP quanto com a UI client-side.

Lançando Erros no Servidor#

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

Os códigos de erro do tRPC mapeiam para códigos de status HTTP:

Código tRPCStatus HTTPQuando Usar
BAD_REQUEST400Input inválido além da validação Zod
UNAUTHORIZED401Não autenticado
FORBIDDEN403Autenticado mas sem permissão suficiente
NOT_FOUND404Recurso não existe
CONFLICT409Recurso duplicado
TOO_MANY_REQUESTS429Rate limit excedido
INTERNAL_SERVER_ERROR500Erro inesperado do servidor

Formatação Customizada de Erros#

Lembra do formatador de erros do nosso setup? Veja como funciona na prática:

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,
        // Adicionar campos customizados
        timestamp: new Date().toISOString(),
        requestId: crypto.randomUUID(),
      },
    };
  },
});

Tratamento de Erros no Cliente#

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("Post excluído");
      utils.post.list.invalidate();
    },
    onError: (error) => {
      switch (error.data?.code) {
        case "FORBIDDEN":
          toast.error("Você não tem permissão para excluir este post");
          break;
        case "NOT_FOUND":
          toast.error("Este post não existe mais");
          utils.post.list.invalidate();
          break;
        default:
          toast.error("Algo deu errado. Por favor, tente novamente.");
      }
    },
  });
 
  return (
    <button
      onClick={() => deletePost.mutate({ id: postId })}
      disabled={deletePost.isPending}
    >
      {deletePost.isPending ? "Excluindo..." : "Excluir"}
    </button>
  );
}

Tratamento Global de Erros#

Você pode configurar um handler global de erros que captura todos os erros tRPC não tratados:

typescript
// No seu TRPCProvider
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        mutations: {
          onError: (error) => {
            // Fallback global para erros de mutation não tratados
            if (error instanceof TRPCClientError) {
              if (error.data?.code === "UNAUTHORIZED") {
                // Redirecionar para login
                window.location.href = "/login";
                return;
              }
 
              toast.error(error.message);
            }
          },
        },
      },
    })
);

Mutations e Atualizações Otimistas#

Mutations são onde o tRPC realmente se integra bem com o TanStack Query. Vamos ver um padrão do mundo real: um botão de curtida com atualizações otimistas.

Mutation Básica#

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

Atualizações Otimistas#

O usuário clica em "Curtir." Você não quer esperar 200ms pela resposta do servidor para atualizar a UI. Atualizações otimistas resolvem isso: atualize a UI imediatamente, depois reverta se o servidor rejeitar.

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 }) => {
      // Cancela refetches em andamento para não sobrescrever nossa atualização otimista
      await utils.post.byId.cancel({ id: postId });
 
      // Snapshot do valor anterior
      const previousPost = utils.post.byId.getData({ id: postId });
 
      // Atualiza o cache otimisticamente
      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,
        };
      });
 
      // Retorna o snapshot para rollback
      return { previousPost };
    },
 
    onError: (_error, { postId }, context) => {
      // Reverte para o valor anterior em caso de erro
      if (context?.previousPost) {
        utils.post.byId.setData({ id: postId }, context.previousPost);
      }
    },
 
    onSettled: (_data, _error, { postId }) => {
      // Sempre refaz fetch após erro ou sucesso para garantir o estado do servidor
      utils.post.byId.invalidate({ id: postId });
    },
  });
 
  return (
    <button
      onClick={() => toggleLike.mutate({ postId })}
      className={initialLiked ? "text-red-500" : "text-gray-400"}
    >
      ♥ {initialCount}
    </button>
  );
}

O padrão é sempre o mesmo:

  1. onMutate: Cancela queries, faz snapshot dos dados atuais, aplica atualização otimista, retorna snapshot.
  2. onError: Reverte usando o snapshot.
  3. onSettled: Invalida a query para que faça refetch do servidor, independente de sucesso ou erro.

Essa dança de três passos garante que a UI é sempre responsiva e eventualmente consistente com o servidor.

Invalidando Queries Relacionadas#

Após uma mutation, você frequentemente precisa atualizar dados relacionados. O useUtils() do tRPC torna isso ergonômico:

typescript
const createComment = trpc.comment.create.useMutation({
  onSuccess: (_data, variables) => {
    // Invalida a lista de comentários do post
    utils.comment.listByPost.invalidate({ postId: variables.postId });
 
    // Invalida o próprio post (contagem de comentários mudou)
    utils.post.byId.invalidate({ id: variables.postId });
 
    // Invalida TODAS as listas de posts (contagens de comentários nas views de lista)
    utils.post.list.invalidate();
  },
});

Batching e Subscriptions#

HTTP Batching#

Por padrão, tRPC com httpBatchLink combina múltiplas requisições simultâneas em uma única chamada HTTP. Se um componente renderiza e dispara três queries:

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

Essas três queries são automaticamente agrupadas em uma única requisição HTTP: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...

O servidor processa todas as três, retorna todos os três resultados em uma única resposta, e o TanStack Query distribui os resultados para cada hook. Nenhuma configuração necessária.

Você pode desabilitar o batching para chamadas específicas se necessário:

typescript
// No seu provider, use splitLink para rotear procedures específicas de forma diferente
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,
        }),
      }),
    ],
  })
);

Subscriptions WebSocket#

Para funcionalidades em tempo real, o tRPC suporta subscriptions via WebSockets. Isso requer um servidor WebSocket separado (o Next.js não suporta WebSockets nativamente em Route Handlers).

typescript
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
 
// Event emitter em memória (use Redis pub/sub em produção)
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);
      };
    });
  }),
 
  // Mutation que dispara uma notificação
  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;
    }),
});

No cliente:

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("Erro na subscription:", error);
    },
  });
 
  return (
    <div>
      <span className="badge">{notifications.length}</span>
      {/* UI da lista de notificações */}
    </div>
  );
}

Para o transporte WebSocket, você precisa de um processo de servidor dedicado. Aqui está um setup mínimo com a biblioteca ws:

typescript
// ws-server.ts (processo separado)
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(`Conexão aberta (${wss.clients.size} total)`);
  ws.once("close", () => {
    console.log(`Conexão fechada (${wss.clients.size} total)`);
  });
});
 
process.on("SIGTERM", () => {
  handler.broadcastReconnectNotification();
  wss.close();
});
 
console.log("Servidor WebSocket escutando em ws://localhost:3001");

E o cliente precisa de um wsLink para subscriptions:

typescript
import { wsLink, createWSClient } from "@trpc/client";
 
const wsClient = createWSClient({
  url: "ws://localhost:3001",
});
 
// Use splitLink para rotear subscriptions via 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,
        }),
      }),
    ],
  })
);

Upload de Arquivos Type-Safe#

O tRPC não lida com upload de arquivos nativamente. É um protocolo JSON-RPC — dados binários não são sua especialidade. Mas você pode construir um fluxo de upload type-safe combinando tRPC com URLs presigned.

O padrão:

  1. O cliente pede ao tRPC uma URL presigned de upload.
  2. O tRPC valida a requisição, verifica permissões, gera a URL.
  3. O cliente faz upload diretamente para o S3 usando a URL presigned.
  4. O cliente notifica o tRPC que o upload está completo.
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 minutos
      });
 
      // Armazenar upload pendente no banco de dados
      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" });
      }
 
      // Verificar se o arquivo realmente existe no S3
      // (opcional mas recomendado)
 
      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}`,
      };
    }),
});

No cliente:

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 {
        // Passo 1: Obter URL presigned do tRPC
        const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        });
 
        // Passo 2: Upload diretamente para o S3
        const uploadResponse = await fetch(presignedUrl, {
          method: "PUT",
          body: file,
          headers: {
            "Content-Type": file.type,
          },
        });
 
        if (!uploadResponse.ok) {
          throw new Error("Upload falhou");
        }
 
        // Passo 3: Confirmar upload via tRPC
        const { url } = await confirmUpload.mutateAsync({ uploadId });
 
        toast.success(`Arquivo enviado: ${url}`);
      } catch (error) {
        toast.error("Upload falhou. Por favor, tente novamente.");
      } finally {
        setUploading(false);
      }
    },
    [getPresignedUrl, confirmUpload]
  );
 
  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
        accept="image/*"
      />
      {uploading && <p>Enviando...</p>}
    </div>
  );
}

Todo o fluxo é type-safe. O tipo da resposta da URL presigned, o tipo do ID de upload, a resposta de confirmação — tudo inferido das definições do servidor. Se você adicionar um novo campo à resposta da URL presigned, o cliente sabe imediatamente.

Chamadas Server-Side e React Server Components#

Com o Next.js App Router, você frequentemente quer buscar dados em Server Components. O tRPC suporta isso através de callers server-side:

typescript
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
 
// Uso em um Server Component
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
 
export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const ctx = await createTRPCContext({
    req: new Request("http://localhost"),
    resHeaders: new Headers(),
  });
  const caller = createCaller(ctx);
 
  const post = await caller.post.byId({ id: params.id });
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      {/* Componentes client para partes interativas */}
      <LikeButton postId={post.id} initialLiked={post.liked} />
      <CommentSection postId={post.id} />
    </article>
  );
}

Isso dá o melhor dos dois mundos: dados iniciais renderizados no servidor com type safety completo, e interatividade client-side para mutations e funcionalidades em tempo real.

Testando Procedures tRPC#

Testar é direto porque procedures são apenas funções. Você não precisa levantar um servidor 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("retorna o perfil do usuário atual", 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("lança UNAUTHORIZED para requisições não autenticadas", async () => {
    const caller = createCaller({
      user: null,
      db: prismaMock,
      session: null,
      headers: {},
    });
 
    await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
  });
 
  it("valida input com 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: "", // comprimento mínimo 1
      })
    ).rejects.toThrow();
  });
});

Sem mock de camadas HTTP, sem supertest, sem correspondência de rotas. Apenas chame a função e verifique o resultado. Esta é uma das vantagens subestimadas do tRPC: testar é trivialmente simples porque a camada de transporte é um detalhe de implementação.

Quando NÃO Usar tRPC#

O tRPC não é uma solução universal. Veja onde ele falha:

APIs Públicas#

Se você está construindo uma API que desenvolvedores externos vão consumir, tRPC é a escolha errada. Consumidores externos não têm acesso aos seus tipos TypeScript. Eles precisam de um contrato documentado e estável — OpenAPI/Swagger para REST, ou um schema GraphQL. A type safety do tRPC só funciona quando tanto o cliente quanto o servidor compartilham o mesmo codebase TypeScript.

Apps Mobile (A Menos Que Use TypeScript)#

Se seu app mobile é escrito em Swift, Kotlin ou Dart, o tRPC não oferece nada. Os tipos não cruzam fronteiras de linguagem. Você poderia teoricamente gerar uma spec OpenAPI a partir das rotas tRPC usando trpc-openapi, mas nesse ponto você está adicionando cerimônia de volta. Use REST desde o início.

Microsserviços#

O tRPC assume um único codebase TypeScript. Se seu backend está dividido entre múltiplos serviços em linguagens diferentes, o tRPC não pode ajudar com comunicação inter-serviços. Use gRPC, REST ou filas de mensagens para isso.

Times Grandes com Repos Separados de Frontend/Backend#

Se seu frontend e backend vivem em repositórios separados com pipelines de deploy separados, você perde a vantagem central do tRPC. O compartilhamento de tipos requer um monorepo ou um pacote compartilhado. Você pode publicar o tipo AppRouter como um pacote npm, mas agora tem um problema de versionamento que REST + OpenAPI lida mais naturalmente.

Quando Precisa de Semântica REST#

Se você precisa de headers de cache HTTP, content negotiation, ETags ou outras funcionalidades específicas de REST, a abstração do tRPC sobre HTTP vai lutar contra você. O tRPC trata HTTP como um detalhe de transporte, não como uma funcionalidade.

O Framework de Decisão#

Veja como eu decido:

CenárioRecomendação
App fullstack TypeScript no mesmo repotRPC — benefício máximo, overhead mínimo
Ferramenta interna / painel admintRPC — velocidade de desenvolvimento é a prioridade
API pública para devs terceirosREST + OpenAPI — consumidores precisam de docs, não de tipos
Clientes mobile + web (mobile não-TS)REST ou GraphQL — precisam de contratos agnósticos de linguagem
Muito tempo real (chat, gaming)tRPC subscriptions ou WebSockets puro dependendo da complexidade
Times separados de frontend/backendGraphQL — o schema é o contrato entre os times

Dicas Práticas de Produção#

Algumas coisas que aprendi rodando tRPC em produção que não estão na documentação:

Mantenha os routers pequenos. Um único arquivo de router não deveria exceder 200 linhas. Divida por domínio: userRouter, postRouter, billingRouter. Cada um em seu próprio arquivo.

Use createCallerFactory para chamadas server-side. Não recorra ao fetch ao chamar sua própria API de um Server Component. A caller factory dá a mesma type safety com zero overhead HTTP.

Não otimize demais o batching. O httpBatchLink padrão é quase sempre suficiente. Já vi times gastarem dias configurando splitLink para ganhos marginais. Faça profiling primeiro.

Configure staleTime no QueryClient. O staleTime padrão de 0 significa que todo evento de foco dispara um refetch. Configure para algo razoável (30 segundos a 5 minutos) baseado nos seus requisitos de frescor de dados.

Use superjson desde o primeiro dia. Adicioná-lo depois significa migrar todo cliente e servidor simultaneamente. É uma configuração de uma linha que salva você de bugs de serialização de Date.

Error boundaries são seus amigos. Envolva seções de página pesadas em tRPC com React error boundaries. Uma única query falhada não deveria derrubar a página inteira.

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">Algo deu errado</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"
      >
        Tentar novamente
      </button>
    </div>
  );
}
 
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <UserStats />
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <RecentPosts />
      </ErrorBoundary>
    </div>
  );
}

Conclusão#

O tRPC não é um substituto para REST ou GraphQL. É uma ferramenta diferente para uma situação específica: quando você controla tanto o cliente quanto o servidor, ambos são TypeScript, e você quer o caminho mais curto possível de "mudei o backend" para "o frontend sabe disso."

Nessa situação, nada mais chega perto. Sem geração de código, sem arquivos de schema, sem divergência. Apenas TypeScript fazendo o que TypeScript faz melhor: capturar erros antes que cheguem à produção.

O trade-off é claro: você abre mão de interoperabilidade a nível de protocolo (sem clientes não-TypeScript) em troca de velocidade de desenvolvimento e segurança em tempo de compilação que é difícil de alcançar de qualquer outra forma.

Para a maioria das aplicações fullstack TypeScript, esse é um trade-off que vale a pena.

Posts Relacionados