Saltar al contenido

API full-stack con tipado seguro usando tRPC y Next.js

Tutorial completo de APIs con tipado seguro de extremo a extremo con tRPC y Next.js App Router: router, middleware, validación y suscripciones.

6 min de lectura
Cadena de API con tipado seguro que conecta componentes de cliente con procedimientos de servidor

Tipado seguro de extremo a extremo: por qué importa

Defines un endpoint de API que devuelve un objeto de usuario. El equipo de backend añade un campo. El equipo de frontend no lo sabe. La aplicación falla en producción a las 2 AM porque alguien accedió a una propiedad que ya no existe.

Este escenario es la consecuencia natural de tener código de cliente y servidor tipados por separado. Las APIs REST no tienen conexión de tipos entre el handler que produce los datos y el componente que los consume. GraphQL añade tipos de esquema, pero requiere generación de código para cerrar la brecha. tRPC elimina la brecha por completo: los tipos de tu backend son los tipos de tu frontend, sin ninguna generación de código.

Este tutorial construye una aplicación full-stack con tRPC y Next.js App Router, mostrando cómo el tipado seguro fluye desde las consultas a la base de datos, pasando por los procedimientos de la API, hasta los componentes de React.

Configurando el router de tRPC

El router es la abstracción central de tRPC. Define procedimientos (queries, mutations, subscriptions) con validación de entradas y cadenas de middleware.

tstypescript
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
import superjson from "superjson";
import { getServerSession } from "next-auth";
 
interface Context {
  session: { user: { id: string; email: string; role: string } } | null;
  db: typeof prisma;
}
 
export async function createContext(): Promise<Context> {
  const session = await getServerSession();
  return {
    session,
    db: prisma,
  };
}
 
const t = initTRPC.context<Context>().create({
  transformer: superjson, // Handles Date, Map, Set serialization
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof z.ZodError
            ? error.cause.flatten()
            : null,
      },
    };
  },
});
 
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;

La factoría de contexto se ejecuta en cada petición, proporcionando la sesión y el cliente de base de datos. El transformador superjson maneja tipos que JSON no puede representar de forma nativa: los objetos Date, Maps, Sets y BigInts se serializan y deserializan correctamente.

Cadenas de middleware para autenticación y autorización

El middleware de tRPC se compone de forma limpia. Cada middleware puede modificar el contexto, validar precondiciones o cortocircuitar con un error.

tstypescript
// src/server/middleware.ts
import { TRPCError } from "@trpc/server";
import { middleware, publicProcedure } from "./trpc";
 
const isAuthenticated = middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in",
    });
  }
 
  return next({
    ctx: {
      ...ctx,
      session: ctx.session, // Narrowed type: session is non-null
    },
  });
});
 
const isAdmin = middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user || ctx.session.user.role !== "admin") {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Admin access required",
    });
  }
 
  return next({ ctx });
});
 
// Composable procedures with middleware stacking
export const protectedProcedure = publicProcedure.use(isAuthenticated);
export const adminProcedure = publicProcedure
  .use(isAuthenticated)
  .use(isAdmin);

Después de que isAuthenticated se ejecuta, el tipo del contexto se estrecha: los procedimientos posteriores saben que la sesión no es nula. Esta es la ventaja del tipado seguro: el middleware modifica simultáneamente el comportamiento en tiempo de ejecución y los tipos en tiempo de compilación.

Construyendo routers de dominio

Cada dominio tiene su propio router con procedimientos para queries y mutations.

tstypescript
// src/server/routers/posts.ts
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import { protectedProcedure, adminProcedure } from "../middleware";
 
const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10).max(50000),
  tags: z.array(z.string()).min(1).max(10),
  published: z.boolean().default(false),
});
 
const listPostsSchema = z.object({
  cursor: z.string().optional(),
  limit: z.number().min(1).max(100).default(20),
  tag: z.string().optional(),
});
 
export const postsRouter = router({
  list: publicProcedure
    .input(listPostsSchema)
    .query(async ({ input, ctx }) => {
      const posts = await ctx.db.post.findMany({
        where: input.tag ? { tags: { has: input.tag } } : undefined,
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        orderBy: { createdAt: "desc" },
        include: { author: { select: { name: true, image: true } } },
      });
 
      let nextCursor: string | undefined;
      if (posts.length > input.limit) {
        const extra = posts.pop()!;
        nextCursor = extra.id;
      }
 
      return { posts, nextCursor };
    }),
 
  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
        include: {
          author: { select: { name: true, image: true } },
          comments: {
            include: { author: { select: { name: true } } },
            orderBy: { createdAt: "asc" },
          },
        },
      });
 
      if (!post) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
 
      return post;
    }),
 
  create: protectedProcedure
    .input(createPostSchema)
    .mutation(async ({ input, ctx }) => {
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.session.user.id,
        },
      });
    }),
 
  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input, ctx }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
      });
 
      if (!post) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
 
      if (post.authorId !== ctx.session.user.id) {
        throw new TRPCError({ code: "FORBIDDEN" });
      }
 
      return ctx.db.post.delete({ where: { id: input.id } });
    }),
});

Cada entrada se valida mediante esquemas de Zod en el límite del procedimiento. Las peticiones inválidas nunca llegan a tus consultas de base de datos. La información de tipos fluye automáticamente: no hay definiciones de tipos manuales para las formas de petición o respuesta.

Fusionando routers y creando el handler de la API

tstypescript
// src/server/routers/index.ts
import { router } from "../trpc";
import { postsRouter } from "./posts";
import { usersRouter } from "./users";
import { commentsRouter } from "./comments";
 
export const appRouter = router({
  posts: postsRouter,
  users: usersRouter,
  comments: commentsRouter,
});
 
export type AppRouter = typeof appRouter;
tstypescript
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers";
import { createContext } from "@/server/trpc";
 
const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext,
  });
 
export { handler as GET, handler as POST };

La exportación del tipo AppRouter es el puente. El cliente importa este tipo —no la implementación— y obtiene inferencia de tipos completa para cada procedimiento.

Configuración del cliente con integración de React Query

tRPC v11 se integra con TanStack Query (React Query) para caché, refetching, actualizaciones optimistas y queries infinitas.

tstypescript
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers";
 
export const trpc = createTRPCReact<AppRouter>();
tsxtsx
// src/app/providers.tsx
"use client";
 
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
import superjson from "superjson";
 
export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: "/api/trpc",
          transformer: superjson,
        }),
      ],
    })
  );
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

El httpBatchLink agrupa automáticamente múltiples llamadas de tRPC del mismo ciclo de renderizado en una sola petición HTTP. Tres hooks useQuery que se disparan simultáneamente resultan en una petición de red, no en tres.

Consumiendo procedimientos en componentes

tsxtsx
// ❌ Bad: Untyped fetch with manual types
async function loadPosts() {
  const res = await fetch("/api/posts?limit=20");
  const data: any = await res.json(); // No type safety
  return data.posts; // Might not exist
}
tsxtsx
// ✅ Good: Fully typed tRPC query
"use client";
 
import { trpc } from "@/lib/trpc";
 
function PostsList() {
  const { data, isLoading, error, fetchNextPage, hasNextPage } =
    trpc.posts.list.useInfiniteQuery(
      { limit: 20 },
      {
        getNextPageParam: (lastPage) => lastPage.nextCursor,
      }
    );
 
  if (isLoading) return <PostsSkeleton />;
  if (error) return <ErrorDisplay message={error.message} />;
 
  const allPosts = data.pages.flatMap((page) => page.posts);
 
  return (
    <div>
      {allPosts.map((post) => (
        <PostCard
          key={post.id}
          title={post.title}
          // TypeScript knows exactly what fields exist
          author={post.author.name}
          createdAt={post.createdAt} // Date object, not string
        />
      ))}
 
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load more</button>
      )}
    </div>
  );
}

Pasa el cursor sobre post en tu editor: TypeScript conoce la forma exacta, incluidas las relaciones anidadas. Renombra un campo en el esquema de Prisma y el compilador te muestra cada componente que necesita actualizarse. Sin tipos obsoletos, sin sorpresas en tiempo de ejecución.

Actualizaciones optimistas para mutations

tsxtsx
function CreateComment({ postId }: { postId: string }) {
  const utils = trpc.useUtils();
 
  const createComment = trpc.comments.create.useMutation({
    onMutate: async (newComment) => {
      await utils.posts.byId.cancel({ id: postId });
 
      const previousData = utils.posts.byId.getData({ id: postId });
 
      utils.posts.byId.setData({ id: postId }, (old) => {
        if (!old) return old;
        return {
          ...old,
          comments: [
            ...old.comments,
            {
              id: "temp-id",
              content: newComment.content,
              author: { name: "You" },
              createdAt: new Date(),
            },
          ],
        };
      });
 
      return { previousData };
    },
    onError: (_err, _vars, context) => {
      if (context?.previousData) {
        utils.posts.byId.setData({ id: postId }, context.previousData);
      }
    },
    onSettled: () => {
      utils.posts.byId.invalidate({ id: postId });
    },
  });
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const form = new FormData(e.currentTarget);
        createComment.mutate({
          postId,
          content: form.get("content") as string,
        });
      }}
    >
      <textarea name="content" required />
      <button type="submit" disabled={createComment.isPending}>
        Comment
      </button>
    </form>
  );
}

Las actualizaciones optimistas muestran los cambios al instante mientras la mutation se ejecuta en segundo plano. Si la mutation falla, el handler onError revierte al estado anterior. El handler onSettled vuelve a obtener los datos reales sin importar si hubo éxito o fallo.

Conclusiones clave

tRPC colapsa la brecha entre los tipos del backend y del frontend en un tipado seguro sin pasos de generación. Cambia un tipo de retorno en tu procedimiento y TypeScript señala inmediatamente cada componente que lo consume. Esto no es una mejora incremental: es una experiencia de desarrollo fundamentalmente diferente.

El stack —tRPC + Zod + Prisma + TanStack Query— te da tipado seguro desde el esquema de la base de datos, pasando por la validación de la API, hasta el renderizado de componentes. Cada capa infiere los tipos de la anterior, creando una cadena donde un cambio de esquema se propaga por toda la aplicación en tiempo de compilación.

La contrapartida es el acoplamiento. tRPC funciona mejor cuando tu frontend y backend viven en el mismo proyecto TypeScript. Para APIs públicas consumidas por terceros, REST o GraphQL con tipos de SDK generados sigue siendo la elección correcta. Para aplicaciones full-stack mantenidas por un solo equipo, tRPC elimina una categoría entera de bugs.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX