Saltar al contenido
·27 min de lectura

tRPC: seguridad de tipos de extremo a extremo sin ceremonia

Cómo tRPC elimina el problema del contrato de API, funciona con Next.js App Router, maneja middleware de autenticación, subida de archivos y suscripciones. Un setup real de tRPC desde cero.

Compartir:X / TwitterLinkedIn

Ya conoces la historia. Cambias el nombre de un campo en tu respuesta de API. Actualizas el tipo en el backend. Despliegas. Luego el frontend se rompe en producción porque alguien olvidó actualizar la llamada fetch en la línea 247 de Dashboard.tsx. El campo ahora es undefined, el componente renderiza en blanco y tu seguimiento de errores se ilumina a las 2 AM.

Este es el problema del contrato de API. No es un problema de tecnología. Es un problema de coordinación. Y ninguna cantidad de documentación Swagger o esquemas GraphQL va a arreglar el hecho de que los tipos de tu frontend y backend pueden divergir silenciosamente.

tRPC soluciona esto negándose a dejarlos divergir. No hay archivo de esquema. No hay paso de generación de código. No hay contrato separado que mantener. Escribes una función TypeScript en el servidor, y el cliente conoce sus tipos exactos de entrada y salida en tiempo de compilación. Si renombras un campo, el frontend no compilará hasta que lo corrijas.

Esa es la promesa. Permíteme mostrarte cómo funciona realmente, dónde brilla y dónde definitivamente no deberías usarlo.

El problema, más precisamente#

Veamos cómo la mayoría de los equipos construyen APIs hoy.

REST + OpenAPI: Escribes tus endpoints. Quizás añades anotaciones Swagger. Quizás generas un SDK de cliente desde la especificación OpenAPI. Pero la especificación es un artefacto separado. Puede quedar obsoleta. El paso de generación es otra cosa en tu pipeline de CI que puede romperse u olvidarse. Y los tipos generados suelen ser feos — monstruos profundamente anidados como paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"].

GraphQL: Mejor seguridad de tipos, pero enorme ceremonia. Escribes un esquema en SDL. Escribes resolvers. Generas tipos desde el esquema. Escribes queries en el cliente. Generas tipos desde las queries. Eso son al menos dos pasos de generación de código, un archivo de esquema y un paso de build que todos tienen que recordar ejecutar. Para un equipo que ya controla tanto frontend como backend, es mucha infraestructura para un problema que tiene una solución más simple.

Llamadas fetch manuales: El enfoque más común y el más peligroso. Escribes fetch("/api/users"), casteas el resultado a User[] y cruzas los dedos. No hay seguridad en tiempo de compilación. La aserción de tipo es una mentira que le cuentas a TypeScript.

typescript
// The lie every frontend developer has told
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 hope this is right

tRPC toma un enfoque completamente diferente. En lugar de describir tu API en un formato separado y generar tipos, escribes funciones TypeScript planas en el servidor e importas sus tipos directamente en el cliente. Sin paso de generación. Sin archivo de esquema. Sin divergencia.

Conceptos fundamentales#

Antes de configurar nada, entendamos el modelo mental.

Router#

Un router tRPC es una colección de procedimientos agrupados. Piensa en ello como un controlador en MVC, excepto que es simplemente un objeto plano con inferencia de tipos incorporada.

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;

Esa exportación de tipo AppRouter es todo el truco de magia. El cliente importa este tipo — no el código de runtime, solo el tipo — y obtiene autocompletado completo y verificación de tipos para cada procedimiento.

Procedimiento#

Un procedimiento es un solo endpoint. Hay tres tipos:

  • Query: Operaciones de lectura. Se mapea a la semántica HTTP GET. Cacheado por TanStack Query.
  • Mutation: Operaciones de escritura. Se mapea a HTTP POST. No se cachea.
  • Subscription: Streams en tiempo real. Usa WebSockets.

Contexto#

El contexto son los datos con alcance de request disponibles para cada procedimiento. Conexiones a base de datos, el usuario autenticado, headers de request — cualquier cosa que pondrías en el objeto req de Express va aquí.

Middleware#

El middleware transforma el contexto o controla el acceso. El patrón más común es un middleware de autenticación que verifica una sesión válida y añade ctx.user.

La cadena de inferencia de tipos#

Este es el modelo mental crítico. Cuando defines un procedimiento así:

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

El tipo de retorno fluye hasta el cliente. Si db.user.findUnique retorna User | null, el hook useQuery del cliente tendrá data tipado como User | null. Sin tipado manual. Sin casting. Se infiere de extremo a extremo.

Configuración con Next.js App Router#

Vamos a construir esto desde cero. Asumiré que tienes un proyecto Next.js 14+ con el App Router.

Instalar dependencias#

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

Paso 1: Inicializar tRPC en el servidor#

Crea tu instancia de tRPC y define el tipo de contexto.

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;

Algunas cosas a notar:

  • Transformer superjson: tRPC serializa datos como JSON por defecto, lo que significa que objetos Date, Map, Set y otros tipos no-JSON se pierden. superjson los preserva.
  • Formateador de errores: Adjuntamos errores de validación Zod a la respuesta para que el cliente pueda mostrar errores a nivel de campo.
  • createTRPCContext: Esta función se ejecuta en cada request. Es donde parseas la sesión, configuras la conexión a base de datos y construyes el objeto de contexto.

Paso 2: Definir tu 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;

Paso 3: Exponer mediante Route Handler de Next.js#

En el App Router, tRPC se ejecuta como un Route Handler estándar. Sin servidor personalizado, sin plugin especial de 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 };

Eso es todo. Tanto GET como POST se manejan. Las queries pasan por GET (con input codificado en URL), las mutations por POST.

Paso 4: Configurar el 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 como tipo únicamente. Ningún código del servidor se filtra al bundle del cliente.

Paso 5: Configuración del 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 (relevant part)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

Paso 6: Usar en 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>Welcome back, {user.name}</h1>
      <p>Member since {user.createdAt.toLocaleDateString()}</p>
    </div>
  );
}

Ese user.name está completamente tipado. Si lo escribes mal como user.nme, TypeScript lo detecta inmediatamente. Si cambias el servidor para retornar displayName en lugar de name, cada uso en el cliente mostrará un error de compilación. Sin sorpresas en runtime.

Contexto y Middleware#

El contexto y el middleware son donde tRPC pasa de "truco de tipos ingenioso" a "framework listo para producción."

Creando el contexto#

La función de contexto se ejecuta en cada request. Aquí hay una versión del 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 autenticación#

El patrón de middleware más común es separar procedimientos públicos de protegidos:

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: {
      // Override the context type — user is no longer nullable
      user: ctx.user,
    },
  });
});
 
export const protectedProcedure = t.procedure.use(isAuthed);

Después de que este middleware se ejecuta, ctx.user en cualquier protectedProcedure está garantizado como no-null. El sistema de tipos lo impone. No puedes accidentalmente acceder a ctx.user.id en un procedimiento público sin que TypeScript se queje.

Middleware basado en roles#

Puedes componer middleware para un control de acceso más 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#

El middleware no es solo para autenticación. Aquí hay un middleware de logging de rendimiento:

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(`⚠️ Slow ${type} ${path}: ${duration}ms`);
  }
 
  return result;
});
 
// Apply to all 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 exceeded. Try again later.`,
    });
  }
 
  return next();
});

Validación de entrada con Zod#

tRPC usa Zod para la validación de entrada. Esto no es decoración opcional — es el mecanismo que asegura que las entradas sean seguras tanto en el cliente como en el servidor.

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

El truco de la doble validación#

Aquí hay algo sutil: la validación Zod se ejecuta en ambos lados. En el cliente, tRPC valida la entrada antes de enviar la solicitud. Si la entrada es inválida, la solicitud nunca sale del navegador. En el servidor, el mismo esquema valida de nuevo como medida de seguridad.

Esto significa que obtienes validación instantánea en el lado del cliente gratis:

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 errors arrive structured
      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 ? "Creating..." : "Create Post"}
      </button>
    </form>
  );
}

Patrones de entrada complejos#

typescript
// Discriminated unions
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(),
  }),
]);
 
// Pagination input reused across 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,
      };
    }),
});

Entradas opcionales#

No todos los procedimientos necesitan entrada. Las queries frecuentemente no la necesitan:

typescript
const statsRouter = router({
  // No input needed
  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 };
  }),
 
  // Optional filters
  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,
      });
    }),
});

Manejo de errores#

El manejo de errores de tRPC es estructurado, con seguridad de tipos e integra limpiamente tanto con la semántica HTTP como con la UI del lado del cliente.

Lanzando errores en el 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 };
    }),
});

Los códigos de error de tRPC se mapean a códigos de estado HTTP:

Código tRPCEstado HTTPCuándo usar
BAD_REQUEST400Entrada inválida más allá de la validación Zod
UNAUTHORIZED401No autenticado
FORBIDDEN403Autenticado pero permisos insuficientes
NOT_FOUND404El recurso no existe
CONFLICT409Recurso duplicado
TOO_MANY_REQUESTS429Límite de tasa excedido
INTERNAL_SERVER_ERROR500Error inesperado del servidor

Formateo personalizado de errores#

¿Recuerdas el formateador de errores de nuestra configuración? Así funciona en la práctica:

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,
        // Add custom fields
        timestamp: new Date().toISOString(),
        requestId: crypto.randomUUID(),
      },
    };
  },
});

Manejo de errores en el 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 deleted");
      utils.post.list.invalidate();
    },
    onError: (error) => {
      switch (error.data?.code) {
        case "FORBIDDEN":
          toast.error("You don't have permission to delete this post");
          break;
        case "NOT_FOUND":
          toast.error("This post no longer exists");
          utils.post.list.invalidate();
          break;
        default:
          toast.error("Something went wrong. Please try again.");
      }
    },
  });
 
  return (
    <button
      onClick={() => deletePost.mutate({ id: postId })}
      disabled={deletePost.isPending}
    >
      {deletePost.isPending ? "Deleting..." : "Delete"}
    </button>
  );
}

Manejo global de errores#

Puedes configurar un manejador global de errores que captura todos los errores tRPC no manejados:

typescript
// In your TRPCProvider
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        mutations: {
          onError: (error) => {
            // Global fallback for unhandled mutation errors
            if (error instanceof TRPCClientError) {
              if (error.data?.code === "UNAUTHORIZED") {
                // Redirect to login
                window.location.href = "/login";
                return;
              }
 
              toast.error(error.message);
            }
          },
        },
      },
    })
);

Mutations y actualizaciones optimistas#

Las mutations son donde tRPC realmente se integra bien con TanStack Query. Veamos un patrón del mundo real: un botón de "me gusta" con actualizaciones optimistas.

Mutation básica#

typescript
// Server
const postRouter = router({
  toggleLike: protectedProcedure
    .input(z.object({ postId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.db.like.findUnique({
        where: {
          userId_postId: {
            userId: ctx.user.id,
            postId: input.postId,
          },
        },
      });
 
      if (existing) {
        await ctx.db.like.delete({
          where: { id: existing.id },
        });
        return { liked: false };
      }
 
      await ctx.db.like.create({
        data: {
          userId: ctx.user.id,
          postId: input.postId,
        },
      });
 
      return { liked: true };
    }),
});

Actualizaciones optimistas#

El usuario hace clic en "Me gusta." No quieres esperar 200ms por la respuesta del servidor para actualizar la UI. Las actualizaciones optimistas resuelven esto: actualiza la UI inmediatamente, luego revierte si el servidor rechaza.

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 }) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await utils.post.byId.cancel({ id: postId });
 
      // Snapshot the previous value
      const previousPost = utils.post.byId.getData({ id: postId });
 
      // Optimistically update the cache
      utils.post.byId.setData({ id: postId }, (old) => {
        if (!old) return old;
        return {
          ...old,
          liked: !old.liked,
          likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
        };
      });
 
      // Return the snapshot for rollback
      return { previousPost };
    },
 
    onError: (_error, { postId }, context) => {
      // Roll back to the previous value on error
      if (context?.previousPost) {
        utils.post.byId.setData({ id: postId }, context.previousPost);
      }
    },
 
    onSettled: (_data, _error, { postId }) => {
      // Always refetch after error or success to ensure server state
      utils.post.byId.invalidate({ id: postId });
    },
  });
 
  return (
    <button
      onClick={() => toggleLike.mutate({ postId })}
      className={initialLiked ? "text-red-500" : "text-gray-400"}
    >
      ♥ {initialCount}
    </button>
  );
}

El patrón siempre es el mismo:

  1. onMutate: Cancelar queries, hacer snapshot de los datos actuales, aplicar actualización optimista, retornar snapshot.
  2. onError: Revertir usando el snapshot.
  3. onSettled: Invalidar la query para que re-fetchee desde el servidor, sin importar si fue éxito o error.

Esta danza de tres pasos asegura que la UI sea siempre responsiva y eventualmente consistente con el servidor.

Invalidando queries relacionadas#

Después de una mutation, frecuentemente necesitas refrescar datos relacionados. El useUtils() de tRPC hace esto ergonómico:

typescript
const createComment = trpc.comment.create.useMutation({
  onSuccess: (_data, variables) => {
    // Invalidate the post's comment list
    utils.comment.listByPost.invalidate({ postId: variables.postId });
 
    // Invalidate the post itself (comment count changed)
    utils.post.byId.invalidate({ id: variables.postId });
 
    // Invalidate ALL post lists (comment counts in list views)
    utils.post.list.invalidate();
  },
});

Batching y suscripciones#

HTTP Batching#

Por defecto, tRPC con httpBatchLink combina múltiples solicitudes simultáneas en una sola llamada HTTP. Si un componente renderiza y dispara tres queries:

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

Estas tres queries se agrupan automáticamente en una sola solicitud HTTP: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...

El servidor procesa las tres, retorna los tres resultados en una sola respuesta, y TanStack Query distribuye los resultados a cada hook. Sin configuración necesaria.

Puedes deshabilitar el batching para llamadas específicas si es necesario:

typescript
// In your provider, use splitLink to route specific procedures differently
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,
        }),
      }),
    ],
  })
);

Suscripciones WebSocket#

Para funcionalidades en tiempo real, tRPC soporta suscripciones sobre WebSockets. Esto requiere un servidor WebSocket separado (Next.js no soporta nativamente WebSockets en Route Handlers).

typescript
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
 
// In-memory event emitter (use Redis pub/sub in production)
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 that triggers a notification
  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;
    }),
});

En el 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("Subscription error:", error);
    },
  });
 
  return (
    <div>
      <span className="badge">{notifications.length}</span>
      {/* notification list UI */}
    </div>
  );
}

Para el transporte WebSocket, necesitas un proceso de servidor dedicado. Aquí hay una configuración mínima con la librería ws:

typescript
// ws-server.ts (separate process)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
 
const wss = new WebSocketServer({ port: 3001 });
 
const handler = applyWSSHandler({
  wss,
  router: appRouter,
  createContext: createTRPCContext,
});
 
wss.on("connection", (ws) => {
  console.log(`Connection opened (${wss.clients.size} total)`);
  ws.once("close", () => {
    console.log(`Connection closed (${wss.clients.size} total)`);
  });
});
 
process.on("SIGTERM", () => {
  handler.broadcastReconnectNotification();
  wss.close();
});
 
console.log("WebSocket server listening on ws://localhost:3001");

Y el cliente necesita un wsLink para las suscripciones:

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

Subida de archivos con seguridad de tipos#

tRPC no maneja subida de archivos nativamente. Es un protocolo JSON-RPC — los datos binarios no son su especialidad. Pero puedes construir un flujo de subida con seguridad de tipos combinando tRPC con URLs pre-firmadas.

El patrón:

  1. El cliente pide a tRPC una URL pre-firmada de subida.
  2. tRPC valida la solicitud, verifica permisos, genera la URL.
  3. El cliente sube directamente a S3 usando la URL pre-firmada.
  4. El cliente notifica a tRPC que la subida está completa.
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 minutes
      });
 
      // Store pending upload in database
      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" });
      }
 
      // Verify the file actually exists in S3
      // (optional but recommended)
 
      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}`,
      };
    }),
});

En el 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 {
        // Step 1: Get presigned URL from tRPC
        const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        });
 
        // Step 2: Upload directly to S3
        const uploadResponse = await fetch(presignedUrl, {
          method: "PUT",
          body: file,
          headers: {
            "Content-Type": file.type,
          },
        });
 
        if (!uploadResponse.ok) {
          throw new Error("Upload failed");
        }
 
        // Step 3: Confirm upload via tRPC
        const { url } = await confirmUpload.mutateAsync({ uploadId });
 
        toast.success(`File uploaded: ${url}`);
      } catch (error) {
        toast.error("Upload failed. Please try again.");
      } finally {
        setUploading(false);
      }
    },
    [getPresignedUrl, confirmUpload]
  );
 
  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
        accept="image/*"
      />
      {uploading && <p>Uploading...</p>}
    </div>
  );
}

Todo el flujo tiene seguridad de tipos. El tipo de respuesta de la URL pre-firmada, el tipo del ID de subida, la respuesta de confirmación — todo inferido desde las definiciones del servidor. Si añades un campo nuevo a la respuesta de la URL pre-firmada, el cliente lo sabe inmediatamente.

Llamadas del lado del servidor y React Server Components#

Con Next.js App Router, frecuentemente quieres obtener datos en Server Components. tRPC soporta esto a través de callers del lado del servidor:

typescript
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
 
// Usage in a 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>
      {/* Client components for interactive parts */}
      <LikeButton postId={post.id} initialLiked={post.liked} />
      <CommentSection postId={post.id} />
    </article>
  );
}

Esto te da lo mejor de ambos mundos: datos renderizados en el servidor con seguridad de tipos completa, e interactividad del lado del cliente para mutations y funcionalidades en tiempo real.

Probando procedimientos tRPC#

Las pruebas son sencillas porque los procedimientos son simplemente funciones. No necesitas levantar un 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("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();
  });
});

Sin mocking de capas HTTP, sin supertest, sin matching de rutas. Solo llama la función y verifica el resultado. Esta es una de las ventajas poco apreciadas de tRPC: las pruebas son trivialmente simples porque la capa de transporte es un detalle de implementación.

Cuándo NO usar tRPC#

tRPC no es una solución universal. Aquí es donde se desmorona:

APIs públicas#

Si estás construyendo una API que consumidores externos van a usar, tRPC es la elección incorrecta. Los consumidores externos no tienen acceso a tus tipos TypeScript. Necesitan un contrato documentado y estable — OpenAPI/Swagger para REST, o un esquema GraphQL. La seguridad de tipos de tRPC solo funciona cuando tanto el cliente como el servidor comparten el mismo código base TypeScript.

Apps móviles (a menos que uses TypeScript)#

Si tu app móvil está escrita en Swift, Kotlin o Dart, tRPC no ofrece nada. Los tipos no cruzan fronteras de lenguaje. Teóricamente podrías generar una especificación OpenAPI desde las rutas tRPC usando trpc-openapi, pero a ese punto estás añadiendo ceremonia de vuelta. Simplemente usa REST desde el inicio.

Microservicios#

tRPC asume un único código base TypeScript. Si tu backend está dividido en múltiples servicios en diferentes lenguajes, tRPC no puede ayudar con la comunicación inter-servicios. Usa gRPC, REST o colas de mensajes para eso.

Equipos grandes con repos separados de frontend/backend#

Si tu frontend y backend viven en repositorios separados con pipelines de despliegue separados, pierdes la ventaja central de tRPC. El compartir tipos requiere un monorepo o un paquete compartido. Puedes publicar el tipo AppRouter como un paquete npm, pero ahora tienes un problema de versionado que REST + OpenAPI maneja más naturalmente.

Cuando necesitas semántica REST#

Si necesitas headers de caché HTTP, negociación de contenido, ETags u otras funcionalidades específicas de REST, la abstracción de tRPC sobre HTTP luchará contra ti. tRPC trata HTTP como un detalle de transporte, no como una funcionalidad.

El marco de decisión#

Así es como yo decido:

EscenarioRecomendación
App fullstack TypeScript en el mismo repotRPC — máximo beneficio, mínimo overhead
Herramienta interna / panel de administracióntRPC — la velocidad de desarrollo es la prioridad
API pública para desarrolladores tercerosREST + OpenAPI — los consumidores necesitan docs, no tipos
Clientes móvil + web (móvil no-TS)REST o GraphQL — se necesitan contratos agnósticos del lenguaje
Mucho tiempo real (chat, gaming)Suscripciones tRPC o WebSockets puros dependiendo de la complejidad
Equipos separados de frontend/backendGraphQL — el esquema es el contrato entre equipos

Consejos prácticos desde producción#

Algunas cosas que he aprendido ejecutando tRPC en producción que no están en la documentación:

Mantén los routers pequeños. Un solo archivo de router no debería exceder 200 líneas. Divide por dominio: userRouter, postRouter, billingRouter. Cada uno en su propio archivo.

Usa createCallerFactory para llamadas del lado del servidor. No recurras a fetch cuando llames a tu propia API desde un Server Component. El caller factory te da la misma seguridad de tipos con cero overhead HTTP.

No sobre-optimices el batching. El httpBatchLink por defecto es casi siempre suficiente. He visto equipos pasar días configurando splitLink para ganancias marginales. Perfilado primero.

Establece staleTime en QueryClient. El staleTime por defecto de 0 significa que cada evento de foco dispara un refetch. Establécelo en algo razonable (30 segundos a 5 minutos) basándote en tus requisitos de frescura de datos.

Usa superjson desde el día uno. Añadirlo después significa migrar cada cliente y servidor simultáneamente. Es una configuración de una línea que te salva de bugs de serialización de Date.

Los error boundaries son tus amigos. Envuelve secciones de página con mucho tRPC en error boundaries de React. Una sola query fallida no debería tumbar la página entera.

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">Something went wrong</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"
      >
        Try again
      </button>
    </div>
  );
}
 
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <UserStats />
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <RecentPosts />
      </ErrorBoundary>
    </div>
  );
}

Conclusión#

tRPC no es un reemplazo para REST o GraphQL. Es una herramienta diferente para una situación específica: cuando controlas tanto el cliente como el servidor, ambos son TypeScript, y quieres el camino más corto posible desde "cambié el backend" hasta "el frontend lo sabe."

En esa situación, nada más se le acerca. Sin generación de código, sin archivos de esquema, sin divergencia. Solo TypeScript haciendo lo que TypeScript hace mejor: detectar errores antes de que lleguen a producción.

El compromiso es claro: renuncias a la interoperabilidad a nivel de protocolo (sin clientes no-TypeScript) a cambio de velocidad de desarrollo y seguridad en tiempo de compilación que es difícil de lograr de otra manera.

Para la mayoría de las aplicaciones fullstack TypeScript, ese es un compromiso que vale la pena hacer.

Artículos relacionados