Zum Inhalt springen

Eine typsichere Full-Stack-API mit tRPC und Next.js bauen

Vollständiges Tutorial für durchgängig typsichere APIs mit tRPC und Next.js App Router: Router-Setup, Middleware, Validierung und Subscriptions.

6 Min. Lesezeit
Typsichere API-Kette, die Client-Komponenten mit Server-Prozeduren verbindet

End-to-End-Typsicherheit: Warum sie wichtig ist

Du definierst einen API-Endpunkt, der ein User-Objekt zurückgibt. Das Backend-Team fügt ein Feld hinzu. Das Frontend-Team weiß nichts davon. Die App stürzt um 2 Uhr nachts in der Produktion ab, weil jemand auf eine Property zugegriffen hat, die nicht mehr existiert.

Dieses Szenario ist die natürliche Folge von getrennt typisiertem Client- und Server-Code. REST-APIs haben keine Typverbindung zwischen dem Handler, der Daten produziert, und der Komponente, die sie konsumiert. GraphQL fügt Schema-Typen hinzu, braucht aber Codegenerierung, um die Lücke zu schließen. tRPC eliminiert die Lücke vollständig — deine Backend-Typen sind deine Frontend-Typen, ganz ohne Codegenerierung.

Dieses Tutorial baut eine Full-Stack-Anwendung mit tRPC und Next.js App Router und zeigt, wie Typsicherheit von Datenbankabfragen über API-Prozeduren bis zu React-Komponenten fließt.

Den tRPC-Router einrichten

Der Router ist die zentrale Abstraktion von tRPC. Er definiert Prozeduren (Queries, Mutations, Subscriptions) mit Eingabevalidierung und Middleware-Ketten.

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;

Die Context-Factory läuft bei jedem Request und stellt die Session und den Datenbank-Client bereit. Der superjson-Transformer behandelt Typen, die JSON nicht nativ darstellen kann — Date-Objekte, Maps, Sets und BigInts werden alle korrekt serialisiert und deserialisiert.

Middleware-Ketten für Authentifizierung und Autorisierung

tRPC-Middleware lässt sich sauber komponieren. Jede Middleware kann den Kontext verändern, Vorbedingungen prüfen oder mit einem Fehler vorzeitig abbrechen.

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

Nachdem isAuthenticated gelaufen ist, verengt sich der Kontext-Typ — nachgelagerte Prozeduren wissen, dass die Session nicht null ist. Das ist der Vorteil der Typsicherheit: Die Middleware verändert gleichzeitig das Laufzeitverhalten und die Compile-Zeit-Typen.

Domain-Router bauen

Jede Domain bekommt ihren eigenen Router mit Prozeduren für Queries und 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 } });
    }),
});

Jede Eingabe wird an der Prozedurgrenze durch Zod-Schemas validiert. Ungültige Requests erreichen deine Datenbankabfragen nie. Die Typinformation fließt automatisch — keine manuellen Typdefinitionen für Request- oder Response-Formen.

Router mergen und den API-Handler erstellen

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

Der Export des Typs AppRouter ist die Brücke. Der Client importiert diesen Typ — nicht die Implementierung — und bekommt vollständige Typinferenz für jede Prozedur.

Client-Setup mit React-Query-Integration

tRPC v11 integriert sich mit TanStack Query (React Query) für Caching, Refetching, optimistische Updates und Infinite Queries.

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

Der httpBatchLink bündelt automatisch mehrere tRPC-Aufrufe aus demselben Render-Zyklus in einen einzigen HTTP-Request. Drei gleichzeitig gefeuerte useQuery-Hooks ergeben einen Netzwerk-Request, nicht drei.

Prozeduren in Komponenten konsumieren

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

Hover in deinem Editor über post — TypeScript kennt die exakte Form, inklusive verschachtelter Relationen. Benenne ein Feld im Prisma-Schema um, und der Compiler zeigt dir jede Komponente, die angepasst werden muss. Keine veralteten Typen, keine Überraschungen zur Laufzeit.

Optimistische Updates für 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>
  );
}

Optimistische Updates zeigen Änderungen sofort, während die Mutation im Hintergrund läuft. Schlägt die Mutation fehl, rollt der onError-Handler auf den vorherigen Zustand zurück. Der onSettled-Handler lädt die echten Daten neu — unabhängig von Erfolg oder Misserfolg.

Die wichtigsten Erkenntnisse

tRPC schließt die Lücke zwischen Backend- und Frontend-Typen zu Typsicherheit ganz ohne Generierungsschritt. Ändere einen Rückgabetyp in deiner Prozedur, und TypeScript markiert sofort jede konsumierende Komponente. Das ist keine inkrementelle Verbesserung — es ist ein fundamental anderes Entwicklungserlebnis.

Der Stack — tRPC + Zod + Prisma + TanStack Query — gibt dir Typsicherheit vom Datenbankschema über die API-Validierung bis zum Komponenten-Rendering. Jede Schicht inferiert Typen aus der vorherigen und bildet eine Kette, in der sich eine Schema-Änderung zur Compile-Zeit durch die gesamte Anwendung zieht.

Der Trade-off ist die Kopplung. tRPC funktioniert am besten, wenn Frontend und Backend im selben TypeScript-Projekt leben. Für öffentliche APIs, die von Dritten konsumiert werden, sind REST oder GraphQL mit generierten SDK-Typen weiterhin die richtige Wahl. Für Full-Stack-Anwendungen eines einzelnen Teams eliminiert tRPC eine ganze Kategorie von Bugs.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX