Zum Inhalt springen
·24 Min. Lesezeit

tRPC: End-to-End Type Safety ohne Umstände

Wie tRPC das API-Vertragsproblem beseitigt, mit dem Next.js App Router funktioniert, Auth-Middleware, Datei-Uploads und Subscriptions handhabt. Ein praxisnahes tRPC-Setup von Grund auf.

Teilen:X / TwitterLinkedIn

Du kennst das Spiel. Du änderst einen Feldnamen in deiner API-Response. Du aktualisierst den Backend-Typ. Du deployest. Dann bricht das Frontend in der Produktion, weil jemand vergessen hat, den Fetch-Call in Zeile 247 von Dashboard.tsx zu aktualisieren. Das Feld ist jetzt undefined, die Komponente rendert leer, und dein Error-Tracking leuchtet um 2 Uhr nachts auf.

Das ist das API-Vertragsproblem. Es ist kein Technologieproblem. Es ist ein Koordinationsproblem. Und keine noch so gute Swagger-Dokumentation oder GraphQL-Schemas werden die Tatsache beheben, dass deine Frontend- und Backend-Typen stillschweigend auseinanderdriften können.

tRPC behebt das, indem es ein Auseinanderdriften schlicht nicht zulässt. Es gibt keine Schema-Datei. Keinen Codegenerierungsschritt. Keinen separaten Vertrag, den du pflegen musst. Du schreibst eine TypeScript-Funktion auf dem Server, und der Client kennt ihre exakten Input- und Output-Typen zur Kompilierzeit. Wenn du ein Feld umbenennst, kompiliert das Frontend nicht mehr, bis du es korrigierst.

Das ist das Versprechen. Lass mich dir zeigen, wie es tatsächlich funktioniert, wo es glänzt und wo du es auf keinen Fall einsetzen solltest.

Das Problem, genauer betrachtet#

Schauen wir uns an, wie die meisten Teams heute APIs bauen.

Der klassische REST-Ansatz#

typescript
// Backend: Du definierst eine Route
app.get("/api/users/:id", async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.params.id } });
  res.json(user);
});
 
// Frontend: Du rufst sie ab und hoffst auf das Beste
const res = await fetch(`/api/users/${id}`);
const user = await res.json(); // Typ: any
// TypeScript hat keine Ahnung, was user ist
// Du castest es oder erstellst einen manuellen Typ

Du erstellst manuell eine Typdefinition auf dem Frontend. Vielleicht hast du ein types/api.ts mit Interfaces, die die Serverantwort widerspiegeln. Du hältst sie synchron... anfangs. Dann fügt jemand dem Backend ein Feld hinzu und vergisst den Frontend-Typ. Oder jemand benennt ein Feld um. Oder eine Nullable-Eigenschaft wird nicht-nullable. Dein TypeScript-Code kompiliert einwandfrei, aber zur Laufzeit bekommst du Cannot read property 'name' of undefined.

Der GraphQL-Ansatz#

GraphQL verbessert die Situation mit einem Schema:

graphql
type User {
  id: ID!
  name: String!
  email: String!
}
 
type Query {
  user(id: ID!): User
}

Du generierst TypeScript-Typen aus diesem Schema. Besser. Aber jetzt hast du einen Codegenerierungsschritt. Du führst graphql-codegen aus, er liest dein Schema, erzeugt Typen, und dein Frontend importiert sie. Wenn du vergisst, den Codegenerator auszuführen, hast du das gleiche Driftproblem. Und du pflegst ein Schema, das ein separates Artefakt vom eigentlichen Code ist.

Der tRPC-Ansatz#

typescript
// Server: Du definierst eine Prozedur
const userRouter = router({
  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return db.user.findUnique({ where: { id: input.id } });
    }),
});
 
// Client: TypeScript kennt den exakten Rückgabetyp
const { data: user } = trpc.user.byId.useQuery({ id: "123" });
// user ist vollständig typisiert: { id: string; name: string; email: string; ... }
// Wenn du user.nme tippst, meldet TypeScript sofort einen Fehler

Keine Codegenerierung. Keine Schema-Datei. Die Typen fließen automatisch vom Server zum Client durch TypeScript's typeof-Inferenz. Die einzige Quelle der Wahrheit ist die Serverfunktion selbst.

Wie es unter der Haube funktioniert#

tRPC's Magie ist kein wirklicher Trick — es ist clevere Nutzung von TypeScript's Typ-Inferenz:

  1. Du definierst deine Routen in TypeScript. Jede Route hat einen Input-Typ (via Zod-Schema) und einen Output-Typ (inferiert aus dem Rückgabewert).
  2. Du exportierst den Typ des Routers (nicht die Laufzeitreferenz): export type AppRouter = typeof appRouter;
  3. Der Client importiert diesen Typ und tRPC's createTRPCReact<AppRouter>() generiert typisierte Hooks für jede Prozedur.
  4. TypeScript's strukturelle Typisierung erledigt den Rest — der Client weiß genau, was jede Prozedur akzeptiert und zurückgibt.

Wichtig: Kein Servercode wird ins Client-Bundle geleakt. Der AppRouter-Typ ist ein reiner TypeScript-Typ — er verschwindet nach der Kompilierung. Es werden nur Typinformationen geteilt, nicht die Implementierung.

Setup von Grund auf mit Next.js App Router#

Ich zeige dir ein komplettes Setup. Nicht die Minimal-Version aus der Doku, sondern eine produktionsreife Struktur.

Schritt 1: Installation#

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

Jedes Paket hat einen bestimmten Zweck:

  • @trpc/server — Serverseitige Router-Definition
  • @trpc/client — Clientseitige API-Aufrufe
  • @trpc/react-query — React-Hooks (baut auf TanStack Query auf)
  • @tanstack/react-query — Datenabruf, Caching, Synchronisation
  • zod — Input-Validierung (sowohl Schema als auch Laufzeitprüfung)
  • superjson — Serialisierung von Daten, JSON nicht bewältigen kann (Dates, Maps, Sets)

Schritt 2: Server-Setup#

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) {
  // Hier würdest du eine Sitzung holen, DB-Verbindung einrichten usw.
  return {
    user: null, // Wird per Middleware gesetzt
    db: prisma,
    headers: Object.fromEntries(opts.req.headers),
  };
}
 
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 router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
typescript
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
 
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).optional(),
        bio: z.string().max(500).optional(),
        image: z.string().url().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const updated = await ctx.db.user.update({
        where: { id: ctx.user.id },
        data: input,
      });
 
      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;

Schritt 3: Per Next.js Route Handler bereitstellen#

Im App Router läuft tRPC als Standard-Route-Handler. Kein benutzerdefinierter Server, kein spezielles Next.js-Plugin.

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

Das war's. Sowohl GET als auch POST werden behandelt. Queries laufen über GET (mit URL-kodierten Inputs), Mutations über POST.

Schritt 4: Client einrichten#

typescript
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
 
export const trpc = createTRPCReact<AppRouter>();

Beachte: Wir importieren AppRouter nur als Typ. Kein Servercode gelangt ins Client-Bundle.

Schritt 5: Provider-Setup#

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 (relevanter Teil)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

Schritt 6: In Komponenten verwenden#

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>Willkommen zurück, {user.name}</h1>
      <p>Mitglied seit {user.createdAt.toLocaleDateString()}</p>
    </div>
  );
}

Dieses user.name ist vollständig typisiert. Wenn du es als user.nme vertippst, fängt TypeScript das sofort ab. Wenn du den Server änderst, damit er displayName statt name zurückgibt, zeigt jede Client-Verwendung einen Kompilierfehler. Keine Laufzeitüberraschungen.

Context und Middleware#

Context und Middleware sind der Punkt, an dem tRPC von „netter Typ-Trick" zu „produktionsreifem Framework" aufsteigt.

Context erstellen#

Die Context-Funktion wird bei jeder Anfrage ausgeführt. Hier ist eine praxisnahe Version:

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

Auth-Middleware#

Das häufigste Middleware-Muster ist die Trennung öffentlicher von geschützten Prozeduren:

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: {
      // Den Context-Typ überschreiben — user ist nicht mehr nullable
      user: ctx.user,
    },
  });
});
 
export const protectedProcedure = t.procedure.use(isAuthed);

Nach dieser Middleware ist ctx.user in jeder protectedProcedure garantiert nicht-null. Das Typsystem erzwingt das. Du kannst nicht versehentlich auf ctx.user.id in einer öffentlichen Prozedur zugreifen, ohne dass TypeScript sich beschwert.

Rollenbasierte Middleware#

Du kannst Middleware für granularere Zugriffskontrolle komponieren:

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

Logging-Middleware#

Middleware ist nicht nur für Auth. Hier eine Performance-Logging-Middleware:

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;
});
 
// Auf alle Prozeduren anwenden
export const publicProcedure = t.procedure.use(loggerMiddleware);

Rate-Limiting-Middleware#

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

Input-Validierung mit Zod#

tRPC verwendet Zod für die Input-Validierung. Das ist keine optionale Dekoration — es ist der Mechanismus, der sicherstellt, dass Inputs sowohl auf Client als auch auf Server sicher sind.

Grundlegende Validierung#

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 ist vollständig typisiert:
      // {
      //   title: string;
      //   content: string;
      //   categoryId: string;
      //   tags: string[];
      //   published: boolean;
      // }
 
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),
});

Der doppelte Validierungstrick#

Hier ist etwas Subtiles: Die Zod-Validierung läuft auf beiden Seiten. Auf dem Client validiert tRPC den Input, bevor die Anfrage gesendet wird. Ist der Input ungültig, verlässt die Anfrage nie den Browser. Auf dem Server validiert dasselbe Schema erneut als Sicherheitsmaßnahme.

Das bedeutet, du bekommst sofortige clientseitige Validierung kostenlos:

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-Fehler kommen strukturiert an
      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 ? "Wird erstellt..." : "Beitrag erstellen"}
      </button>
    </form>
  );
}

Komplexe Input-Muster#

typescript
// Diskriminierte 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(),
  }),
]);
 
// Paginierungs-Input wiederverwendbar über Prozeduren hinweg
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,
      };
    }),
});

Optionale Inputs#

Nicht jede Prozedur braucht Input. Queries oft nicht:

typescript
const statsRouter = router({
  // Kein Input nötig
  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 };
  }),
 
  // Optionale Filter
  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,
      });
    }),
});

Fehlerbehandlung#

tRPC's Fehlerbehandlung ist strukturiert, typsicher und integriert sich sauber sowohl mit HTTP-Semantik als auch mit clientseitiger UI.

Fehler auf dem Server werfen#

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

tRPC-Fehlercodes werden auf HTTP-Statuscodes abgebildet:

tRPC-CodeHTTP-StatusWann verwenden
BAD_REQUEST400Ungültiger Input jenseits der Zod-Validierung
UNAUTHORIZED401Nicht eingeloggt
FORBIDDEN403Eingeloggt, aber unzureichende Berechtigungen
NOT_FOUND404Ressource existiert nicht
CONFLICT409Doppelte Ressource
TOO_MANY_REQUESTS429Rate Limit überschritten
INTERNAL_SERVER_ERROR500Unerwarteter Serverfehler

Benutzerdefinierte Fehlerformatierung#

Erinnerst du dich an den Error Formatter aus unserem Setup? So funktioniert er in der Praxis:

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,
        // Benutzerdefinierte Felder hinzufügen
        timestamp: new Date().toISOString(),
        requestId: crypto.randomUUID(),
      },
    };
  },
});

Clientseitige Fehlerbehandlung#

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("Beitrag gelöscht");
      utils.post.list.invalidate();
    },
    onError: (error) => {
      switch (error.data?.code) {
        case "FORBIDDEN":
          toast.error("Du hast keine Berechtigung, diesen Beitrag zu löschen");
          break;
        case "NOT_FOUND":
          toast.error("Dieser Beitrag existiert nicht mehr");
          utils.post.list.invalidate();
          break;
        default:
          toast.error("Etwas ist schiefgelaufen. Bitte versuche es erneut.");
      }
    },
  });
 
  return (
    <button
      onClick={() => deletePost.mutate({ id: postId })}
      disabled={deletePost.isPending}
    >
      {deletePost.isPending ? "Wird gelöscht..." : "Löschen"}
    </button>
  );
}

Globale Fehlerbehandlung#

Du kannst einen globalen Fehlerbehandler einrichten, der alle unbehandelten tRPC-Fehler abfängt:

typescript
// In deinem TRPCProvider
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        mutations: {
          onError: (error) => {
            // Globaler Fallback für unbehandelte Mutations-Fehler
            if (error instanceof TRPCClientError) {
              if (error.data?.code === "UNAUTHORIZED") {
                // Zur Login-Seite weiterleiten
                window.location.href = "/login";
                return;
              }
 
              toast.error(error.message);
            }
          },
        },
      },
    })
);

Mutations und optimistische Updates#

Mutations sind der Bereich, in dem tRPC wirklich gut mit TanStack Query zusammenspielt. Schauen wir uns ein praxisnahes Muster an: einen Like-Button mit optimistischen Updates.

Einfache Mutation#

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

Optimistische Updates#

Der Nutzer klickt „Gefällt mir". Du willst nicht 200 ms auf die Serverantwort warten, um die UI zu aktualisieren. Optimistische Updates lösen das: Aktualisiere die UI sofort und mache es rückgängig, falls der Server ablehnt.

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 }) => {
      // Ausgehende Refetches abbrechen, damit sie unser optimistisches Update nicht überschreiben
      await utils.post.byId.cancel({ id: postId });
 
      // Vorherigen Wert als Snapshot sichern
      const previousPost = utils.post.byId.getData({ id: postId });
 
      // Cache optimistisch aktualisieren
      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,
        };
      });
 
      // Snapshot für Rollback zurückgeben
      return { previousPost };
    },
 
    onError: (_error, { postId }, context) => {
      // Bei Fehler zum vorherigen Wert zurückkehren
      if (context?.previousPost) {
        utils.post.byId.setData({ id: postId }, context.previousPost);
      }
    },
 
    onSettled: (_data, _error, { postId }) => {
      // Immer nach Fehler oder Erfolg refetchen, um den Serverzustand sicherzustellen
      utils.post.byId.invalidate({ id: postId });
    },
  });
 
  return (
    <button
      onClick={() => toggleLike.mutate({ postId })}
      className={initialLiked ? "text-red-500" : "text-gray-400"}
    >
      ♥ {initialCount}
    </button>
  );
}

Das Muster ist immer dasselbe:

  1. onMutate: Queries abbrechen, aktuelle Daten als Snapshot sichern, optimistisches Update anwenden, Snapshot zurückgeben.
  2. onError: Mittels Snapshot zurückrollen.
  3. onSettled: Query invalidieren, damit sie vom Server refetcht, unabhängig von Erfolg oder Fehler.

Dieser Dreischritt-Tanz stellt sicher, dass die UI immer responsiv und letztendlich konsistent mit dem Server ist.

Verwandte Queries invalidieren#

Nach einer Mutation musst du oft verwandte Daten aktualisieren. tRPC's useUtils() macht das ergonomisch:

typescript
const createComment = trpc.comment.create.useMutation({
  onSuccess: (_data, variables) => {
    // Kommentarliste des Beitrags invalidieren
    utils.comment.listByPost.invalidate({ postId: variables.postId });
 
    // Den Beitrag selbst invalidieren (Kommentaranzahl hat sich geändert)
    utils.post.byId.invalidate({ id: variables.postId });
 
    // ALLE Beitragslisten invalidieren (Kommentaranzahlen in Listenansichten)
    utils.post.list.invalidate();
  },
});

Batching und Subscriptions#

HTTP-Batching#

Standardmäßig kombiniert tRPC mit httpBatchLink mehrere gleichzeitige Anfragen zu einem einzigen HTTP-Call. Wenn eine Komponente rendert und drei Queries auslöst:

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

Diese drei Queries werden automatisch in eine einzige HTTP-Anfrage gebatcht: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...

Der Server verarbeitet alle drei, gibt alle drei Ergebnisse in einer einzigen Antwort zurück, und TanStack Query verteilt die Ergebnisse an jeden Hook. Keine Konfiguration nötig.

Du kannst Batching für bestimmte Aufrufe deaktivieren, falls nötig:

typescript
// In deinem Provider, verwende splitLink, um bestimmte Prozeduren anders zu routen
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,
        }),
      }),
    ],
  })
);

WebSocket-Subscriptions#

Für Echtzeitfunktionen unterstützt tRPC Subscriptions über WebSockets. Das erfordert einen separaten WebSocket-Server (Next.js unterstützt WebSockets in Route Handlers nicht nativ).

typescript
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
 
// In-Memory Event Emitter (verwende Redis Pub/Sub in der Produktion)
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, die eine Benachrichtigung auslöst
  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;
    }),
});

Auf dem Client:

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>
      {/* Benachrichtigungsliste UI */}
    </div>
  );
}

Für den WebSocket-Transport brauchst du einen dedizierten Serverprozess. Hier ein minimales Setup mit der ws-Bibliothek:

typescript
// ws-server.ts (separater Prozess)
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(`Verbindung geöffnet (${wss.clients.size} gesamt)`);
  ws.once("close", () => {
    console.log(`Verbindung geschlossen (${wss.clients.size} gesamt)`);
  });
});
 
process.on("SIGTERM", () => {
  handler.broadcastReconnectNotification();
  wss.close();
});
 
console.log("WebSocket server listening on ws://localhost:3001");

Und der Client braucht einen wsLink für Subscriptions:

typescript
import { wsLink, createWSClient } from "@trpc/client";
 
const wsClient = createWSClient({
  url: "ws://localhost:3001",
});
 
// Verwende splitLink, um Subscriptions über WebSocket zu routen
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,
        }),
      }),
    ],
  })
);

Typsichere Datei-Uploads#

tRPC unterstützt Datei-Uploads nicht nativ. Es ist ein JSON-RPC-Protokoll — Binärdaten sind nicht sein Spezialgebiet. Aber du kannst einen typsicheren Upload-Flow aufbauen, indem du tRPC mit Presigned URLs kombinierst.

Das Muster:

  1. Client fragt tRPC nach einer Presigned Upload URL.
  2. tRPC validiert die Anfrage, prüft Berechtigungen, generiert die URL.
  3. Client lädt direkt zu S3 hoch mit der Presigned URL.
  4. Client benachrichtigt tRPC, dass der Upload abgeschlossen ist.
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 Minuten
      });
 
      // Ausstehenden Upload in der Datenbank speichern
      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" });
      }
 
      // Überprüfen, ob die Datei tatsächlich in S3 existiert
      // (optional, aber empfohlen)
 
      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}`,
      };
    }),
});

Auf dem Client:

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 {
        // Schritt 1: Presigned URL von tRPC holen
        const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        });
 
        // Schritt 2: Direkt zu S3 hochladen
        const uploadResponse = await fetch(presignedUrl, {
          method: "PUT",
          body: file,
          headers: {
            "Content-Type": file.type,
          },
        });
 
        if (!uploadResponse.ok) {
          throw new Error("Upload failed");
        }
 
        // Schritt 3: Upload über tRPC bestätigen
        const { url } = await confirmUpload.mutateAsync({ uploadId });
 
        toast.success(`Datei hochgeladen: ${url}`);
      } catch (error) {
        toast.error("Upload fehlgeschlagen. Bitte versuche es erneut.");
      } finally {
        setUploading(false);
      }
    },
    [getPresignedUrl, confirmUpload]
  );
 
  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
        accept="image/*"
      />
      {uploading && <p>Wird hochgeladen...</p>}
    </div>
  );
}

Der gesamte Flow ist typsicher. Der Presigned-URL-Response-Typ, der Upload-ID-Typ, der Bestätigungs-Response — alles wird aus den Serverdefinitionen inferiert. Wenn du ein neues Feld zur Presigned-URL-Response hinzufügst, weiß der Client sofort davon.

Serverseitige Aufrufe und React Server Components#

Mit dem Next.js App Router möchtest du oft Daten in Server Components laden. tRPC unterstützt das über serverseitige Caller:

typescript
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
 
// Verwendung in einer 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-Komponenten für interaktive Teile */}
      <LikeButton postId={post.id} initialLiked={post.liked} />
      <CommentSection postId={post.id} />
    </article>
  );
}

Das gibt dir das Beste aus beiden Welten: servergerenderte Initialdaten mit voller Typsicherheit und clientseitige Interaktivität für Mutations und Echtzeitfunktionen.

tRPC-Prozeduren testen#

Testen ist unkompliziert, weil Prozeduren einfach Funktionen sind. Du musst keinen HTTP-Server hochfahren.

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

Kein Mocken von HTTP-Schichten, kein supertest, kein Route Matching. Ruf einfach die Funktion auf und prüfe das Ergebnis. Das ist einer von tRPC's unterschätzten Vorteilen: Testen ist trivial einfach, weil die Transportschicht ein Implementierungsdetail ist.

Wann du tRPC NICHT verwenden solltest#

tRPC ist keine Universallösung. Hier gerät es an seine Grenzen:

Öffentliche APIs#

Wenn du eine API baust, die externe Entwickler konsumieren werden, ist tRPC die falsche Wahl. Externe Konsumenten haben keinen Zugriff auf deine TypeScript-Typen. Sie brauchen einen dokumentierten, stabilen Vertrag — OpenAPI/Swagger für REST oder ein GraphQL-Schema. tRPC's Typsicherheit funktioniert nur, wenn sowohl Client als auch Server dieselbe TypeScript-Codebasis teilen.

Mobile Apps (es sei denn, du nutzt TypeScript)#

Wenn deine mobile App in Swift, Kotlin oder Dart geschrieben ist, bietet tRPC nichts. Die Typen überschreiten keine Sprachgrenzen. Du könntest theoretisch eine OpenAPI-Spezifikation aus tRPC-Routen mit trpc-openapi generieren, aber dann fügst du den Overhead wieder hinzu. Verwende von Anfang an REST.

Microservices#

tRPC setzt eine einzelne TypeScript-Codebasis voraus. Wenn dein Backend auf mehrere Dienste in verschiedenen Sprachen aufgeteilt ist, kann tRPC bei der Inter-Service-Kommunikation nicht helfen. Verwende dafür gRPC, REST oder Message Queues.

Große Teams mit separaten Frontend-/Backend-Repos#

Wenn dein Frontend und Backend in separaten Repositories mit separaten Deploy-Pipelines leben, verlierst du tRPC's Kernvorteil. Das Teilen von Typen erfordert ein Monorepo oder ein gemeinsames Paket. Du kannst den AppRouter-Typ als npm-Paket veröffentlichen, aber dann hast du ein Versionierungsproblem, das REST + OpenAPI natürlicher handhabt.

Wenn du REST-Semantik brauchst#

Wenn du HTTP-Caching-Header, Content Negotiation, ETags oder andere REST-spezifische Features brauchst, wird tRPC's Abstraktion über HTTP dir im Weg stehen. tRPC behandelt HTTP als Transportdetail, nicht als Feature.

Der Entscheidungsrahmen#

So entscheide ich:

SzenarioEmpfehlung
Same-Repo Fullstack TypeScript ApptRPC — maximaler Nutzen, minimaler Overhead
Internes Tool / Admin-DashboardtRPC — Entwicklungsgeschwindigkeit hat Priorität
Öffentliche API für Drittanbieter-EntwicklerREST + OpenAPI — Konsumenten brauchen Doku, keine Typen
Mobile + Web Clients (nicht-TS mobile)REST oder GraphQL — sprachunabhängige Verträge nötig
Echtzeit-lastig (Chat, Gaming)tRPC Subscriptions oder Raw WebSockets je nach Komplexität
Separate Frontend-/Backend-TeamsGraphQL — Schema ist der Vertrag zwischen Teams

Praktische Tipps aus der Produktion#

Einige Dinge, die ich vom Betrieb von tRPC in der Produktion gelernt habe und die nicht in der Doku stehen:

Halte Router klein. Eine einzelne Router-Datei sollte 200 Zeilen nicht überschreiten. Aufteilen nach Domäne: userRouter, postRouter, billingRouter. Jeder in seiner eigenen Datei.

Verwende createCallerFactory für serverseitige Aufrufe. Greife nicht zu fetch, wenn du deine eigene API aus einer Server Component aufrufst. Die Caller Factory gibt dir dieselbe Typsicherheit ohne HTTP-Overhead.

Überoptimiere Batching nicht. Der Standard-httpBatchLink reicht fast immer aus. Ich habe Teams gesehen, die Tage mit splitLink-Konfigurationen für marginale Gewinne verbracht haben. Zuerst profilen.

Setze staleTime im QueryClient. Die Standard-staleTime von 0 bedeutet, dass jedes Fokusereignis einen Refetch auslöst. Setze sie auf etwas Vernünftiges (30 Sekunden bis 5 Minuten) basierend auf deinen Datenaktualitätsanforderungen.

Verwende superjson vom ersten Tag an. Es später hinzuzufügen bedeutet, jeden Client und Server gleichzeitig zu migrieren. Es ist eine einzeilige Konfiguration, die dich vor Date-Serialisierungsbugs bewahrt.

Error Boundaries sind dein Freund. Umschließe tRPC-intensive Seitenabschnitte mit React Error Boundaries. Eine einzelne fehlgeschlagene Query sollte nicht die gesamte Seite zum Absturz bringen.

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">Etwas ist schiefgelaufen</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"
      >
        Erneut versuchen
      </button>
    </div>
  );
}
 
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <UserStats />
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <RecentPosts />
      </ErrorBoundary>
    </div>
  );
}

Zusammenfassung#

tRPC ist kein Ersatz für REST oder GraphQL. Es ist ein anderes Werkzeug für eine bestimmte Situation: wenn du sowohl den Client als auch den Server kontrollierst, beide TypeScript sind und du den kürzesten Weg von „Ich habe das Backend geändert" zu „das Frontend weiß davon" willst.

In dieser Situation kommt nichts anderes heran. Keine Codegenerierung, keine Schema-Dateien, kein Drift. Einfach TypeScript, das tut, was TypeScript am besten kann: Fehler abfangen, bevor sie die Produktion erreichen.

Der Kompromiss ist klar: Du gibst Interoperabilität auf Protokollebene auf (keine Nicht-TypeScript-Clients) im Austausch gegen Entwicklungsgeschwindigkeit und Kompilierzeit-Sicherheit, die auf andere Weise schwer zu erreichen ist.

Für die meisten Fullstack-TypeScript-Anwendungen ist das ein lohnenswerter Kompromiss.

Ähnliche Beiträge