tRPC resuelve el problema más tedioso de las APIs REST con TypeScript: mantener sincronizados los tipos del servidor y del cliente. En lugar de generar código a partir de un schema OpenAPI o escribir interfaces duplicadas, tRPC exporta el tipo del router directamente y el cliente lo importa para obtener autocompletado y comprobación de errores en tiempo de compilación.
Crear el router en el servidor
La configuración básica requiere inicializar tRPC y definir el router con sus procedimientos:
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/usuario.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
const usuarioSchema = z.object({
id: z.number(),
nombre: z.string(),
email: z.string().email(),
});
export const usuarioRouter = router({
obtenerTodos: publicProcedure
.output(z.array(usuarioSchema))
.query(async () => {
return db.usuario.findMany();
}),
obtenerPorId: publicProcedure
.input(z.object({ id: z.number() }))
.output(usuarioSchema)
.query(async ({ input }) => {
return db.usuario.findUniqueOrThrow({ where: { id: input.id } });
}),
crear: publicProcedure
.input(z.object({ nombre: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
return db.usuario.create({ data: input });
}),
});
Router raíz y exportar el tipo AppRouter
El router raíz combina todos los sub-routers. El tipo AppRouter se exporta y el cliente lo importa sin importar ningún código del servidor:
// server/routers/index.ts
import { router } from '../trpc';
import { usuarioRouter } from './usuario';
import { productoRouter } from './producto';
export const appRouter = router({
usuario: usuarioRouter,
producto: productoRouter,
});
// SOLO el tipo, no la implementación:
export type AppRouter = typeof appRouter;
Integración con Next.js (App Router)
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
import { createContext } from '@/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };
Cliente tipado con el tipo del servidor
El cliente solo importa el tipo AppRouter. En tiempo de compilación, TypeScript conoce todos los procedimientos, sus inputs y sus outputs:
// utils/trpc.ts (cliente Next.js)
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers';
export const trpc = createTRPCReact<AppRouter>();
// Componente React tipado end-to-end:
function ListaUsuarios() {
// TypeScript sabe que data es Usuario[] | undefined:
const { data, isLoading } = trpc.usuario.obtenerTodos.useQuery();
const crearUsuario = trpc.usuario.crear.useMutation({
onSuccess: (nuevoUsuario) => {
// nuevoUsuario está completamente tipado
console.log(nuevoUsuario.nombre);
},
});
return (
<ul>
{data?.map((u) => (
<li key={u.id}>{u.nombre}</li>
))}
</ul>
);
}
Middleware tipado
Los middlewares de tRPC pueden añadir propiedades al contexto y restringir el tipo del procedimiento resultante:
// Middleware de autenticación:
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.usuario) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
// ctx.session.usuario es definido a partir de aquí:
return next({ ctx: { ...ctx, usuario: ctx.session.usuario } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
// Ahora ctx.usuario está tipado en los procedimientos protegidos:
const perfilRouter = router({
obtenerPerfil: protectedProcedure.query(({ ctx }) => {
return db.usuario.findUnique({ where: { id: ctx.usuario.id } });
}),
});
Subscriptions con WebSocket
import { observable } from '@trpc/server/observable';
const mensajesRouter = router({
onNuevoMensaje: publicProcedure
.input(z.object({ sala: z.string() }))
.subscription(({ input }) => {
return observable<Mensaje>((emit) => {
const unsubscribe = eventosMensajes.on(input.sala, (msg) => {
emit.next(msg);
});
return unsubscribe;
});
}),
});
Imagen: Pexels / Tima Miroshnichenko
