tRPC - Seguridad de Tipos de Extremo a Extremo
Construye APIs completamente type-safe sin esquemas ni generación de código usando tRPC
¿Qué es tRPC?
tRPC (TypeScript Remote Procedure Call) te permite construir APIs completamente type-safe sin esquemas ni generación de código. Aprovecha la inferencia de tipos de TypeScript para compartir automáticamente tipos entre tu servidor y cliente.
Si estás construyendo una aplicación full-stack de TypeScript (como Next.js), tRPC elimina la complejidad tradicional de la capa API mientras proporciona seguridad de tipos completa.
✨ ¿Por Qué tRPC?
Los tipos fluyen del servidor al cliente automáticamente
Sin paso de compilación, actualizaciones de tipos instantáneas
Soporte completo del IDE para llamadas API
~2KB cliente, sin sobrecarga en tiempo de ejecución
Configurando tRPC
// Instalación
// npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
// 1. Define tu router (server/trpc.ts)
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
// Inicializar tRPC
const t = initTRPC.context().create();
// Exportar ayudantes reutilizables de router y procedimiento
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
// 2. Crear tu app router (server/routers/_app.ts)
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
export const appRouter = router({
user: userRouter,
post: postRouter,
});
// Exportar definición de tipo de API
export type AppRouter = typeof appRouter;
Definiendo Procedimientos
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
export const userRouter = router({
// Query - para obtener datos (como GET)
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id }
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Usuario no encontrado'
});
}
return user;
}),
// Query sin entrada
list: publicProcedure
.query(async ({ ctx }) => {
return ctx.db.user.findMany({
take: 10,
orderBy: { createdAt: 'desc' }
});
}),
// Mutation - para cambiar datos (como POST/PUT/DELETE)
create: protectedProcedure
.input(z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user']).optional().default('user')
}))
.mutation(async ({ input, ctx }) => {
// ctx.user está disponible debido a protectedProcedure
const user = await ctx.db.user.create({
data: {
...input,
createdBy: ctx.user.id
}
});
return user;
}),
// Mutation de actualización
update: protectedProcedure
.input(z.object({
id: z.string(),
name: z.string().min(2).optional(),
email: z.string().email().optional()
}))
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
return ctx.db.user.update({
where: { id },
data
});
}),
// Mutation de eliminación
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
await ctx.db.user.delete({
where: { id: input.id }
});
return { success: true };
})
});
Context y Middleware
// server/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import { getSession } from 'next-auth/react';
export async function createContext({ req, res }) {
const session = await getSession({ req });
return {
req,
res,
user: session?.user ?? null,
db: prisma // Tu cliente de base de datos
};
}
export type Context = inferAsyncReturnType;
// Middleware para rutas protegidas
import { TRPCError } from '@trpc/server';
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Debes iniciar sesión'
});
}
return next({
ctx: {
// Infiere que el usuario no es nulo
user: ctx.user
}
});
});
// Middleware de logging
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log(`${type} ${path} - ${duration}ms`);
return result;
});
// Aplicar middleware
export const loggedProcedure = t.procedure.use(loggerMiddleware);
Cliente React con React Query
// utils/trpc.ts - Configuración del cliente
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';
export const trpc = createTRPCReact();
// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '../utils/trpc';
import { useState } from 'react';
export function TRPCProvider({ children }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
authorization: getAuthToken()
};
}
})
]
})
);
return (
{children}
);
}
Usando tRPC en Componentes
// components/UserList.tsx
import { trpc } from '../utils/trpc';
export function UserList() {
// Queries - caché automático y refetch
const { data: users, isLoading, error } = trpc.user.list.useQuery();
// Query con entrada
const { data: user } = trpc.user.getById.useQuery(
{ id: '123' },
{ enabled: !!userId } // Solo ejecutar si userId existe
);
// Mutations
const utils = trpc.useUtils();
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
// Invalidar caché para refetch
utils.user.list.invalidate();
},
onError: (error) => {
alert(error.message);
}
});
const handleSubmit = (data) => {
createUser.mutate({
name: data.name,
email: data.email,
// ¡TypeScript sabe exactamente qué campos son requeridos!
});
};
if (isLoading) return Cargando...;
if (error) return Error: {error.message};
return (
{users?.map(user => (
{user.name} - {user.email}
))}
);
}
// Actualizaciones optimistas
const updateUser = trpc.user.update.useMutation({
onMutate: async (newData) => {
// Cancelar refetches salientes
await utils.user.getById.cancel({ id: newData.id });
// Snapshot del valor actual
const previousUser = utils.user.getById.getData({ id: newData.id });
// Actualizar optimistamente
utils.user.getById.setData({ id: newData.id }, (old) => ({
...old,
...newData
}));
return { previousUser };
},
onError: (err, newData, context) => {
// Revertir en caso de error
utils.user.getById.setData(
{ id: newData.id },
context?.previousUser
);
}
});
Integración con Next.js
// pages/api/trpc/[trpc].ts (Pages Router)
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
import { createContext } from '../../../server/context';
export default createNextApiHandler({
router: appRouter,
createContext,
onError: ({ error, type, path }) => {
console.error(`Error tRPC en ${path}:`, error);
}
});
// app/api/trpc/[trpc]/route.ts (App Router)
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext
});
export { handler as GET, handler as POST };
// Server Components (App Router) - ¡Llamadas directas!
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';
export default async function UsersPage() {
// Llamar tRPC directamente en el servidor - ¡sin HTTP!
const caller = appRouter.createCaller(await createContext());
const users = await caller.user.list();
return (
{users.map(user => (
{user.name}
))}
);
}
Suscripciones (Tiempo Real)
// Servidor - Suscripciones WebSocket
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
const ee = new EventEmitter();
export const postRouter = router({
onNewPost: publicProcedure
.subscription(() => {
return observable((emit) => {
const onAdd = (post: Post) => emit.next(post);
ee.on('newPost', onAdd);
return () => {
ee.off('newPost', onAdd);
};
});
}),
create: protectedProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.db.post.create({ data: input });
// Emitir a suscriptores
ee.emit('newPost', post);
return post;
})
});
// Cliente - Suscribirse a actualizaciones
import { trpc } from '../utils/trpc';
function LiveFeed() {
const [posts, setPosts] = useState([]);
trpc.post.onNewPost.useSubscription(undefined, {
onData: (post) => {
setPosts((prev) => [post, ...prev]);
},
onError: (err) => {
console.error('Error de suscripción:', err);
}
});
return (
{posts.map(post => (
{post.title}
))}
);
}
Manejo de Errores
// Errores del lado del servidor
import { TRPCError } from '@trpc/server';
// Códigos de error integrados
throw new TRPCError({
code: 'BAD_REQUEST', // 400
code: 'UNAUTHORIZED', // 401
code: 'FORBIDDEN', // 403
code: 'NOT_FOUND', // 404
code: 'CONFLICT', // 409
code: 'UNPROCESSABLE_CONTENT', // 422
code: 'TOO_MANY_REQUESTS', // 429
code: 'INTERNAL_SERVER_ERROR', // 500
message: 'Mensaje de error personalizado',
cause: originalError // Opcional
});
// Manejo del lado del cliente
const mutation = trpc.user.create.useMutation({
onError: (error) => {
// error.data contiene el código de error
if (error.data?.code === 'CONFLICT') {
toast.error('El usuario ya existe');
} else if (error.data?.code === 'UNAUTHORIZED') {
router.push('/login');
} else {
toast.error(error.message);
}
}
});
// ¡Los errores de validación Zod son automáticos!
// Si la entrada no coincide con el esquema, el cliente obtiene errores detallados
tRPC vs REST vs GraphQL
| Característica | REST | GraphQL | tRPC |
|---|---|---|---|
| Seguridad de Tipos | Manual | Codegen | Automática ✓ |
| Esquema | OpenAPI | SDL | No necesario ✓ |
| Paso de Compilación | Opcional | Requerido | Ninguno ✓ |
| Lenguaje | Cualquiera | Cualquiera | Solo TypeScript |
| Curva de Aprendizaje | Baja | Media | Baja ✓ |
💡 Mejores Prácticas de tRPC
- ✓ Usa Zod para validación - Validación de entrada e inferencia de tipos
- ✓ Divide routers por dominio - Mantén routers enfocados y organizados
- ✓ Usa middleware - Auth, logging, limitación de tasa
- ✓ Aprovecha React Query - Caché, actualizaciones optimistas, prefetching
- ✓ Manejo de errores - Usa TRPCError con códigos apropiados
- ✓ Server components - Llama tRPC directamente sin HTTP