API & TypeScript • 2026

tRPC mit Claude Code:
End-to-End Typsicherheit 2026

Router, Query/Mutation Procedures, Middleware, Zod-Validierung und React Query Integration — Claude Code generiert typsichere APIs ohne Schema-Drift und ohne REST-Boilerplate.

📅 6. Mai 2026 ⏱ 11 min Lesezeit ✍️ SpockyMagicAI Team
tRPC TypeScript Claude Code Next.js Zod React Query API Design 2026

Inhalt

  1. tRPC Konzepte — Warum kein REST oder GraphQL?
  2. Procedures — Query, Mutation & Subscription
  3. Middleware — Auth, Rollen & Logging
  4. Client-Setup — React Query & TRPC-Hooks
  5. Next.js App Router Integration
  6. Error-Handling & Subscriptions

TypeScript ist längst der Standard für moderne Full-Stack-Entwicklung — doch die klassische Grenze zwischen Backend und Frontend bleibt eine Fehlerquelle: REST-APIs mit manuell synchronisierten Typen, GraphQL-Schemas die driften, OpenAPI-Generatoren die Abweichungen produzieren. tRPC löst dieses Problem radikal anders: keine Schema-Definition, keine Code-Generierung, kein Drift. Stattdessen eine einzige TypeScript-Typdefinition, die vom Server direkt zum Client fließt.

Mit Claude Code als KI-Pair-Programmer wird tRPC noch kraftvoller: Der Assistent versteht Router-Typen, schlägt korrekte Procedure-Signaturen vor, generiert Middleware-Chains und warnt bei Zod-Schema-Inkompatibilitäten — bevor der TypeScript-Compiler es tut. Dieser Artikel zeigt, wie das in der Praxis 2026 aussieht.

Voraussetzungen: TypeScript-Grundkenntnisse, Node.js 20+, Erfahrung mit React und grundlegendes Verständnis von HTTP-APIs. tRPC v11 (aktuell) und Next.js 15 App Router werden verwendet.

1. tRPC Konzepte — Warum kein REST oder GraphQL?

REST-APIs sind mächtig, aber sie erzeugen strukturellen Overhead: Route-Definitionen, manuelle Typisierung von Request/Response-Objekten, Dokumentation die veraltet. GraphQL behebt das Schema-Problem, führt aber seinen eigenen Boilerplate ein: SDL-Schemas, Resolver, Code-Generatoren, n+1-Probleme.

Kriterium REST GraphQL tRPC
End-to-End Typsicherheit Manuell / Codegen Codegen nötig Nativ, automatisch
Schema-Drift Häufig Selten (SDL) Unmöglich
Boilerplate Hoch Hoch Minimal
Echtzeit / Subscriptions SSE / Polling Nativ Nativ (WS)
Lernkurve Niedrig Hoch Niedrig
Non-TS Clients Ja Ja Eingeschränkt

tRPC ist kein Protokoll — es ist eine TypeScript-Bibliothek. Der gesamte Typsicherheits-Mechanismus läuft zur Compile-Zeit. Im Netzwerk fließt schlicht JSON. Damit ist tRPC ideal für Monorepos und Full-Stack-Projekte, bei denen Backend und Frontend in derselben Codebase leben.

initTRPC — der Einstiegspunkt

Jedes tRPC-Projekt beginnt mit initTRPC. Hier wird der Context-Typ festgelegt — der Container für alles, was Procedures kennen dürfen (User-Session, Datenbankverbindung, Logger).

server/trpc.ts — Basis-Setup
// server/trpc.ts import { initTRPC } from '@trpc/server'; import { ZodError } from 'zod'; import type { Context } from './context'; /** * tRPC-Instanz mit Context-Typ initialisieren. * Claude Code erkennt automatisch, welche Context-Felder * in welchen Procedures verfügbar sind. */ const t = initTRPC.context<Context>().create({ errorFormatter: ({ shape, error }) => ({ ...shape, data: { ...shape.data, // Zod-Fehler als strukturiertes Objekt im Response zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }), }); // Exportierte Primitives export const router = t.router; export const publicProcedure = t.procedure; export const middleware = t.middleware; export const mergeRouters = t.mergeRouters;
server/context.ts — Context-Definition
// server/context.ts import { db } from './db'; import { getSession } from './auth'; import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'; export async function createContext({ req }: FetchCreateContextFnOptions) { const session = await getSession(req); return { db, session, user: session?.user ?? null, }; } export type Context = Awaited<ReturnType<typeof createContext>>; // AppRouter-Typ für den Client exportieren export type { AppRouter } from './router';
Schlüsselkonzept Schema-Drift ist strukturell ausgeschlossen

Da der AppRouter-Typ direkt als TypeScript-Generic an den Client übergeben wird, kennt der Client zur Compile-Zeit die exakten Eingabe- und Ausgabe-Typen jeder Procedure. Ändert sich eine Procedure auf dem Server, schlägt der TypeScript-Compiler auf dem Client fehl — ohne Codegen, ohne CI-Step, ohne manuelle Synchronisation.

2. Procedures — Query, Mutation & Subscription

Procedures sind die Kerneinheit in tRPC — äquivalent zu einem einzelnen API-Endpoint. Es gibt drei Typen: Query (lesend, GET-Semantik), Mutation (schreibend, POST/PUT/DELETE-Semantik) und Subscription (Echtzeit via WebSocket). Alle drei sind vollständig typisiert — Input und Output werden über Zod-Schemas definiert.

Einen Router mit mehreren Procedures aufbauen

server/routers/user.ts — User Router
// server/routers/user.ts import { z } from 'zod'; import { router, publicProcedure, protectedProcedure } from '../trpc'; import { TRPCError } from '@trpc/server'; // Zod-Schema für User-Input — wird Client-seitig UND Server-seitig validiert const createUserSchema = z.object({ name: z.string().min(2).max(50), email: z.string().email(), role: z.enum(['admin', 'user', 'viewer']).default('user'), }); const updateUserSchema = createUserSchema.partial().extend({ id: z.string().cuid(), }); export const userRouter = router({ // QUERY — Liste aller User (paginiert) list: protectedProcedure .input( z.object({ page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20), search: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const { page, limit, search } = input; const offset = (page - 1) * limit; const [users, total] = await ctx.db.user.findManyAndCount({ where: search ? { name: { contains: search, mode: 'insensitive' } } : {}, skip: offset, take: limit, orderBy: { createdAt: 'desc' }, }); return { users, pagination: { page, limit, total, pages: Math.ceil(total / limit), }, }; }), // QUERY — Einzelner User by ID byId: protectedProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) => { const user = await ctx.db.user.findUnique({ where: { id: input.id }, include: { profile: true, _count: { select: { posts: true } } }, }); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: `User mit ID ${input.id} nicht gefunden`, }); } return user; }), // MUTATION — User anlegen create: protectedProcedure .input(createUserSchema) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.user.findUnique({ where: { email: input.email }, }); if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'Diese E-Mail-Adresse ist bereits registriert', }); } return ctx.db.user.create({ data: input }); }), // MUTATION — User aktualisieren update: protectedProcedure .input(updateUserSchema) .mutation(async ({ ctx, input }) => { const { id, ...data } = input; return ctx.db.user.update({ where: { id }, data }); }), // MUTATION — User löschen (nur Admin) delete: adminProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { await ctx.db.user.delete({ where: { id: input.id } }); return { success: true, id: input.id }; }), });
Query Mutation Zod Chainable Procedures

Procedures sind chainbar: .input(schema).query(fn) oder .input(schema).mutation(fn). Claude Code nutzt den vollständigen Typkontext beim Schreiben von Procedures — es kennt die Context-Felder, die Zod-Schemas und alle verfügbaren Datenbankmodelle.

AppRouter zusammensetzen

server/router.ts — Root Router
// server/router.ts import { router } from './trpc'; import { userRouter } from './routers/user'; import { postRouter } from './routers/post'; import { authRouter } from './routers/auth'; import { analyticsRouter } from './routers/analytics'; export const appRouter = router({ user: userRouter, post: postRouter, auth: authRouter, analytics: analyticsRouter, }); // AppRouter-Typ wird vom Client importiert — das ist die einzige Abhängigkeit! export type AppRouter = typeof appRouter;

3. Middleware — Auth, Rollen & Logging

Middleware in tRPC funktioniert wie ein Interceptor-Pattern: Sie läuft vor der eigentlichen Procedure, kann den Context anreichern, Fehler werfen oder die Ausführung an den nächsten Handler delegieren. Middleware ist composable — mehrere Middlewares werden einfach verkettet.

server/middleware.ts — Auth, Rollen & Logging
// server/middleware.ts import { middleware, router, publicProcedure } from './trpc'; import { TRPCError } from '@trpc/server'; /** * Logging-Middleware: Misst Ausführungszeit jeder Procedure. * Claude Code generiert diese Middleware automatisch wenn man * "add timing middleware to all procedures" schreibt. */ export const timingMiddleware = middleware(async ({ path, type, next }) => { const start = Date.now(); if (process.env.NODE_ENV === 'development') { const waitMs = Math.floor(Math.random() * 400); await new Promise(resolve => setTimeout(resolve, waitMs)); } const result = await next(); const durationMs = Date.now() - start; console.log(`[tRPC] ${type} ${path} — ${durationMs}ms`); return result; }); /** * Auth-Middleware: Prüft ob ein User eingeloggt ist. * Nach dem next()-Aufruf enthält ctx.user garantiert einen non-null User. */ export const isAuthenticated = middleware(({ ctx, next }) => { if (!ctx.user) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Bitte melde dich an, um fortzufahren', }); } // Context mit garantiertem user-Typ anreichern return next({ ctx: { ...ctx, user: ctx.user, // Typ: NonNullable<Context['user']> }, }); }); /** * Rollen-Middleware: Fabrik-Funktion für verschiedene Rollen. * hasRole('admin') erzeugt eine Middleware die Admin-Zugriff erzwingt. */ export const hasRole = (role: 'admin' | 'moderator') => middleware(({ ctx, next }) => { if (!ctx.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } if (ctx.user.role !== role && ctx.user.role !== 'admin') { throw new TRPCError({ code: 'FORBIDDEN', message: `Rolle '${role}' erforderlich`, }); } return next({ ctx }); }); // Fertige Procedure-Varianten mit angehängter Middleware export const protectedProcedure = publicProcedure .use(timingMiddleware) .use(isAuthenticated); export const adminProcedure = protectedProcedure .use(hasRole('admin')); export const moderatorProcedure = protectedProcedure .use(hasRole('moderator'));
Middleware Claude Code versteht Middleware-Chains

Claude Code erkennt, welche Procedure welche Middleware-Chain hat. Wenn du adminProcedure.mutation() verwendest, weiß der Assistent, dass ctx.user non-null und ctx.user.role === 'admin' ist. Das verhindert unnötige Null-Checks im Procedure-Body.

4. Client-Setup — React Query & tRPC-Hooks

tRPC integriert sich nahtlos mit TanStack Query (React Query). Das Ergebnis: vollständig typisierte Hooks mit automatischem Caching, Background-Refetching, Optimistic Updates und Loading-States — ohne eine einzige manuelle Typ-Annotation auf dem Client.

lib/trpc.ts — Client-Konfiguration
// lib/trpc.ts import { createTRPCReact } from '@trpc/react-query'; import { httpBatchLink, loggerLink } from '@trpc/client'; import { createTRPCClient } from '@trpc/client'; import superjson from 'superjson'; import type { AppRouter } from '../server/router'; // Der AppRouter-Typ ist die einzige Abhängigkeit zum Server export const trpc = createTRPCReact<AppRouter>(); export 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}`; } // Vanilla Client für serverseitige Aufrufe (z.B. Server Actions) export const serverClient = createTRPCClient<AppRouter>({ links: [ httpBatchLink({ url: `${getBaseUrl()}/api/trpc`, transformer: superjson, }), ], });
providers/TRPCProvider.tsx — React Provider
// providers/TRPCProvider.tsx 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { httpBatchLink, loggerLink } from '@trpc/client'; import superjson from 'superjson'; import { useState } from 'react'; import { trpc, getBaseUrl } from '../lib/trpc'; export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 Minute retry: (failureCount, error) => { // Nicht bei 4xx-Fehlern wiederholen if (error.data?.httpStatus < 500) return false; return failureCount < 3; }, }, }, }) ); const [trpcClient] = useState(() => trpc.createClient({ links: [ loggerLink({ enabled: (opts) => process.env.NODE_ENV === 'development' || (opts.direction === 'down' && opts.result instanceof Error), }), httpBatchLink({ url: `${getBaseUrl()}/api/trpc`, transformer: superjson, headers: () => { const token = localStorage.getItem('auth-token'); return token ? { Authorization: `Bearer ${token}` } : {}; }, }), ], }) ); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </trpc.Provider> ); }

Hooks in Komponenten verwenden

components/UserList.tsx — useQuery & useMutation
// components/UserList.tsx 'use client'; import { trpc } from '../lib/trpc'; export function UserList() { // useQuery: automatisch typisiert — data ist User[] | undefined const { data, isLoading, error } = trpc.user.list.useQuery({ page: 1, limit: 20, }); // useMutation: automatisch typisiert const utils = trpc.useUtils(); const deleteUser = trpc.user.delete.useMutation({ onMutate: async ({ id }) => { // Optimistic Update: User sofort aus der Liste entfernen await utils.user.list.cancel(); const prev = utils.user.list.getData(); utils.user.list.setData( { page: 1, limit: 20 }, (old) => old ? { ...old, users: old.users.filter(u => u.id !== id) } : old ); return { prev }; }, onError: (_err, _vars, context) => { // Rollback bei Fehler if (context?.prev) { utils.user.list.setData({ page: 1, limit: 20 }, context.prev); } }, onSettled: () => { utils.user.list.invalidate(); }, }); if (isLoading) return <div>Lade...</div>; if (error) return <div>Fehler: {error.message}</div>; return ( <ul> {data?.users.map((user) => ( <li key={user.id}> {user.name} — {user.email} <button onClick={() => deleteUser.mutate({ id: user.id })}> Löschen </button> </li> ))} </ul> ); }

5. Next.js App Router Integration

tRPC funktioniert nahtlos mit dem Next.js 15 App Router. Der Server-Handler wird als Route Handler eingebunden — und Server Components können tRPC-Procedures direkt aufrufen, ohne HTTP-Overhead.

app/api/trpc/[trpc]/route.ts — Route Handler
// app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { appRouter } from '../../../../server/router'; import { createContext } from '../../../../server/context'; import { env } from '../../../../env'; const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext, onError: env.NODE_ENV === 'development' ? ({ path, error }) => { console.error( `❌ tRPC error on ${path ?? '<no path>'}:`, error ); } : undefined, }); export { handler as GET, handler as POST };

Server Components — direkter Procedure-Aufruf

app/users/page.tsx — React Server Component
// app/users/page.tsx — Server Component (kein 'use client')! import { serverClient } from '../../lib/trpc'; import { Suspense } from 'react'; /** * Server Component: Lädt Daten direkt über tRPC ohne HTTP-Roundtrip. * Claude Code generiert korrektes async/await in Server Components * und weiß, dass serverClient keine React-Hooks verwendet. */ async function UsersData({ page }: { page: number }) { // Direkte Procedure-Aufrufe im Server — kein HTTP, kein Netzwerk const { users, pagination } = await serverClient.user.list.query({ page }); return ( <section> <h1>Nutzer ({pagination.total})</h1> <ul> {users.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> </section> ); } export default function UsersPage({ searchParams, }: { searchParams: { page?: string }; }) { const page = Number(searchParams.page ?? '1'); return ( <main> <Suspense fallback={<p>Lade Nutzer...</p>}> <UsersData page={page} /> </Suspense> </main> ); }
Next.js Hydration ohne Doppel-Fetch

Mit dehydrate und HydrationBoundary von TanStack Query können Server-gefetchte Daten direkt in den Client-Cache übertragen werden. Dadurch gibt es beim ersten Render keinen zweiten Netzwerk-Request — der Client übernimmt den vorgeladenen Zustand nahtlos.

6. Error-Handling & Subscriptions

tRPC hat ein eigenes Error-System das HTTP-Status-Codes auf semantische Fehler-Codes mappt. Fehler werden automatisch typisiert — der Client weiß, welche Fehler eine Procedure werfen kann. Für Echtzeit-Daten bietet tRPC native WebSocket-Subscriptions mit dem observable-Primitiv.

server/routers/notification.ts — Error-Handling & Subscriptions
// server/routers/notification.ts import { z } from 'zod'; import { observable } from '@trpc/server/observable'; import { TRPCError } from '@trpc/server'; import { EventEmitter } from 'events'; import { router, protectedProcedure } from '../trpc'; const ee = new EventEmitter(); type Notification = { id: string; type: 'info' | 'warning' | 'error'; message: string; createdAt: Date; }; export const notificationRouter = router({ // Mutation mit detailliertem Error-Handling send: protectedProcedure .input(z.object({ recipientId: z.string().cuid(), type: z.enum(['info', 'warning', 'error']), message: z.string().min(1).max(500), })) .mutation(async ({ ctx, input }) => { // Rate Limiting prüfen const recentCount = await ctx.db.notification.count({ where: { senderId: ctx.user.id, createdAt: { gt: new Date(Date.now() - 60_000) }, }, }); if (recentCount > 10) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'Maximal 10 Benachrichtigungen pro Minute erlaubt', }); } // Empfänger prüfen const recipient = await ctx.db.user.findUnique({ where: { id: input.recipientId }, }); if (!recipient) { throw new TRPCError({ code: 'NOT_FOUND', message: `Empfänger nicht gefunden`, cause: new Error(`recipientId: ${input.recipientId}`), }); } const notification = await ctx.db.notification.create({ data: { ...input, senderId: ctx.user.id, }, }); // Echtzeit-Event auslösen ee.emit(`notification:${input.recipientId}`, notification); return notification; }), // SUBSCRIPTION — Echtzeit-Benachrichtigungen via WebSocket onNew: protectedProcedure .input(z.object({ userId: z.string().cuid() }).optional()) .subscription(({ ctx, input }) => { const userId = input?.userId ?? ctx.user.id; // Zugriffskontrolle: nur eigene Benachrichtigungen if (userId !== ctx.user.id && ctx.user.role !== 'admin') { throw new TRPCError({ code: 'FORBIDDEN' }); } return observable<Notification>((emit) => { const eventName = `notification:${userId}`; const onData = (notification: Notification) => { emit.next(notification); }; ee.on(eventName, onData); // Cleanup bei Disconnect return () => { ee.off(eventName, onData); }; }); }), });

Client-seitiges Error-Handling

hooks/useNotifications.ts — Subscription + Error-Handling
// hooks/useNotifications.ts 'use client'; import { trpc } from '../lib/trpc'; import { isTRPCClientError } from '@trpc/client'; import { useState, useCallback } from 'react'; export function useNotifications(userId: string) { const [notifications, setNotifications] = useState<Notification[]>([]); // useSubscription — WebSocket-Verbindung trpc.notification.onNew.useSubscription( { userId }, { onData: (notification) => { setNotifications(prev => [notification, ...prev]); }, onError: (err) => { if (isTRPCClientError(err)) { console.error('Subscription-Fehler:', { code: err.data?.code, message: err.message, httpStatus: err.data?.httpStatus, }); } }, } ); const sendMutation = trpc.notification.send.useMutation({ onError: (err) => { // Zod-Fehler aus error.data.zodError if (err.data?.zodError) { console.error('Validierungsfehler:', err.data.zodError.fieldErrors); } }, }); const send = useCallback( (recipientId: string, message: string) => sendMutation.mutateAsync({ recipientId, type: 'info', message, }), [sendMutation] ); return { notifications, send, isSending: sendMutation.isPending }; }
Error-Codes Alle tRPC Error-Codes auf einen Blick
  • PARSE_ERROR → HTTP 400 — Ungültiger JSON-Body
  • BAD_REQUEST → HTTP 400 — Zod-Validierungsfehler
  • UNAUTHORIZED → HTTP 401 — Nicht authentifiziert
  • FORBIDDEN → HTTP 403 — Nicht berechtigt
  • NOT_FOUND → HTTP 404 — Ressource nicht vorhanden
  • CONFLICT → HTTP 409 — Duplikat / Versionskollision
  • TOO_MANY_REQUESTS → HTTP 429 — Rate Limit überschritten
  • INTERNAL_SERVER_ERROR → HTTP 500 — Unbekannter Fehler
  • NOT_IMPLEMENTED → HTTP 501 — Nicht implementiert

Claude Code Workflow-Tipp: Procedures generieren

Claude Code versteht tRPC-Projekte ganzheitlich. Wenn du schreibst: "Erstelle eine Procedure zum Paginierten Laden von Blog-Posts mit Filterung nach Tag und Datum", generiert der Assistent das vollständige Zod-Schema, die Procedure mit korrektem Context-Zugriff, passende Typen für die Paginiererung und sogar den zugehörigen useQuery-Aufruf auf dem Client — alles vollständig typisiert, ohne manuelle Korrekturen.

Wichtiger Hinweis: tRPC eignet sich ideal für Monorepos mit gemeinsamem TypeScript-Code. Für Projekte mit mehreren Clients in verschiedenen Sprachen (iOS, Android, Drittanbieter) bleibt REST oder GraphQL die bessere Wahl. tRPC und REST können auch koexistieren — etwa für interne Dienste tRPC, für externe APIs REST.

tRPC-Projekte mit KI-Unterstützung bauen

SpockyMagicAI hilft dir dabei, typsichere APIs schneller zu entwickeln — von der Router-Architektur über Middleware-Design bis zu komplexen Subscription-Patterns. Starte jetzt kostenlos.

Kostenlos testen →