tRPC & TypeScript

tRPC mit Claude Code: End-to-End Type Safety 2026

tRPC v11: Router, Procedures, Zod-Validierung, React Query Integration, Next.js App Router, Subscriptions — vollständige TypeScript-Type-Safety ohne Code-Gen.

6. Mai 2026 · 11 min Lesezeit

Wer je REST-APIs mit TypeScript gebaut hat, kennt das Problem: Der Server gibt ein Objekt zurück, der Client erwartet ein anderes — und der Fehler taucht erst zur Laufzeit auf. tRPC löst dieses Problem radikal: Es teilt TypeScript-Typen direkt zwischen Server und Client, ohne Code-Generation, ohne OpenAPI-Schemas, ohne manuelle Synchronisation. Claude Code beschleunigt diesen Prozess, indem es Router, Procedures und die gesamte Validierungslogik typsicher generiert.

1. tRPC Grundlagen: initTRPC, Router & AppRouter

tRPC funktioniert über einen zentralen Trick: Der AppRouter-Typ des Servers wird als TypeScript-Import auf dem Client genutzt. Kein HTTP-Schema, kein Code-Generator — nur TypeScript.

Installation

npm install @trpc/server @trpc/client @trpc/react-query @trpc/next npm install zod npm install @tanstack/react-query@^5

initTRPC — der Einstiegspunkt

tRPC Core

src/server/trpc.ts

Die Wurzel jedes tRPC-Setups: initTRPC erzeugt die Bausteine für Router und Procedures.

// src/server/trpc.ts import { initTRPC } from '@trpc/server' import superjson from 'superjson' // Context-Typ wird hier referenziert (später definiert) import type { Context } from './context' const t = initTRPC.context<Context>().create({ transformer: superjson, // Dates, Maps, Sets automatisch serialisieren errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, } }, }) // Exports: Router, publicProcedure — Basis-Bausteine export const router = t.router export const publicProcedure = t.procedure

Ersten Router definieren

// src/server/routers/user.ts import { z } from 'zod' import { router, publicProcedure } from '../trpc' import { db } from '../db' export const userRouter = router({ // Query: Daten lesen getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) => { const user = await db.user.findUnique({ where: { id: input.id } }) if (!user) throw new TRPCError({ code: 'NOT_FOUND' }) return user }), // Mutation: Daten schreiben create: publicProcedure .input(z.object({ name: z.string().min(2).max(100), email: z.string().email(), })) .mutation(async ({ input }) => { return db.user.create({ data: input }) }), })

AppRouter zusammenführen & Typ exportieren

AppRouter

Der kritische Export: AppRouter-Typ

Nur dieser eine Typ-Export verbindet Server und Client — keine Runtime-Dependency, nur TypeScript-Inferenz.

// src/server/routers/_app.ts import { router } from '../trpc' import { userRouter } from './user' import { postRouter } from './post' import { projectRouter } from './project' export const appRouter = router({ user: userRouter, post: postRouter, project: projectRouter, }) // 🔑 Dieser Typ-Export ist der Kern von tRPC export type AppRouter = typeof appRouter
Claude Code Workflow: "Erstelle einen tRPC-Router für User-Management mit CRUD-Operationen, Zod-Validierung und Prisma-Integration" — Claude generiert Router, Procedures, Fehlerbehandlung und den korrekten AppRouter-Export in einem Schritt.

2. Zod Input-Validierung & Output-Typing

Zod ist die natürliche Ergänzung zu tRPC: Input-Schemas werden zur Laufzeit validiert und als TypeScript-Typen inferiert. Das eliminiert doppelte Type-Definitionen komplett.

Zod v3 tRPC v11

Input-Schemas mit z.infer

// Komplexes Input-Schema const CreateProjectSchema = z.object({ name: z.string().min(1).max(200), description: z.string().optional(), status: z.enum(['draft', 'active', 'archived']).default('draft'), tags: z.array(z.string()).max(10).default([]), budget: z.number().positive().optional(), deadline: z.date().optional(), ownerId: z.string().uuid(), }) // TypeScript-Typ ist automatisch inferiert — kein separates Interface nötig! type CreateProjectInput = z.infer<typeof CreateProjectSchema> // { name: string; description?: string; status: 'draft'|'active'|'archived'; ... }

Output-Validierung mit .output()

// Output explizit deklarieren — strippt unerwartete Felder (Security!) const UserOutputSchema = z.object({ id: z.string(), name: z.string(), email: z.string(), createdAt: z.date(), // password wird NICHT eingeschlossen → automatisch gestripped }) projectRouter = router({ create: publicProcedure .input(CreateProjectSchema) .output(z.object({ id: z.string(), name: z.string(), createdAt: z.date(), })) .mutation(async ({ input, ctx }) => { return ctx.db.project.create({ data: { ...input } }) }), })

Fehlerformatierung mit Zod-Details

// Auf dem Client: Zod-Fehlermeldungen sind typsicher verfügbar const createProject = trpc.project.create.useMutation({ onError(err) { if (err.data?.zodError) { const fieldErrors = err.data.zodError.fieldErrors // fieldErrors.name → string[] | undefined console.error('Validierungsfehler:', fieldErrors) } } })

Geschachtelte & bedingte Schemas

// Union-Types für verschiedene Payload-Varianten const EventSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('email'), email: z.string().email() }), z.object({ type: z.literal('webhook'), url: z.string().url() }), z.object({ type: z.literal('slack'), channel: z.string() }), ]) // tRPC-Procedure mit discriminated union Input notifyRoute: publicProcedure .input(EventSchema) .mutation(({ input }) => { switch (input.type) { case 'email': return sendEmail(input.email) case 'webhook': return callWebhook(input.url) case 'slack': return postSlack(input.channel) } })
Zod Power Feature

Transforms & Refinements

z.string().transform(s => s.trim().toLowerCase()) — Daten direkt im Schema transformieren, bevor sie die Procedure erreichen. .refine() für Custom-Validierungen die Zod-Basics nicht abdecken.

3. Middleware & Context

Authentifizierung, Logging, Rate-Limiting — alles, was für mehrere Procedures gilt, gehört in Middleware. tRPC macht das über t.middleware() und typsicheren Context-Zugriff.

Context erstellen

// src/server/context.ts import { inferAsyncReturnType } from '@trpc/server' import { db } from './db' import { getServerSession } from 'next-auth' export async function createContext(opts: { req: Request }) { const session = await getServerSession() return { db, session, user: session?.user ?? null, } } // Typ wird automatisch inferiert — kein manuelles Interface nötig export type Context = inferAsyncReturnType<typeof createContext>

isAuthed Middleware

Middleware

Typsichere Middleware-Komposition

Nach isAuthed ist ctx.user in TypeScript garantiert nicht-null — kein manuelles Casting nötig.

// src/server/trpc.ts (erweitert) const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }) } return next({ ctx: { // TypeScript weiß jetzt: user ist nicht null! user: ctx.user, }, }) }) export const protectedProcedure = t.procedure.use(isAuthed) // Verwendung in Router export const secretRouter = router({ myData: protectedProcedure .query(({ ctx }) => { // ctx.user ist hier garantiert definiert (TypeScript-Error wenn nicht) return db.data.findMany({ where: { userId: ctx.user.id } }) }), })

Rate-Limiting Middleware

// Upstash Redis Rate-Limiting als Middleware import { Ratelimit } from '@upstash/ratelimit' import { Redis } from '@upstash/redis' const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, '10 s'), analytics: true, }) const withRateLimit = t.middleware(async ({ ctx, next }) => { const identifier = ctx.user?.id ?? ctx.ip ?? 'anonymous' const { success } = await ratelimit.limit(identifier) if (!success) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS' }) } return next() }) export const rateLimitedProcedure = t.procedure.use(withRateLimit)

mergeRouters & meta

// Routers zusammenführen ohne Namespace-Kollision import { mergeRouters } from '@trpc/server' const combinedRouter = mergeRouters(userRouter, authRouter) // Meta-Daten für OpenTelemetry oder Custom-Logging const t2 = initTRPC.meta<{ openapi?: { method: string; path: string } }>() .context<Context>() .create() t2.procedure .meta({ openapi: { method: 'GET', path: '/users/{id}' } }) .input(z.object({ id: z.string() })) .query(({ input }) => getUserById(input.id))

4. React Query Integration

tRPC v11 ist tief in TanStack Query v5 integriert. Queries, Mutations, Invalidation — alles vollständig typsicher, mit automatischem Cache-Management.

@trpc/react-query TanStack Query v5

tRPC-Client & Provider einrichten

// src/utils/trpc.ts import { createTRPCReact } from '@trpc/react-query' import type { AppRouter } from '../server/routers/_app' // AppRouter-Typ bindet Client an Server — vollständige Autocompletion! export const trpc = createTRPCReact<AppRouter>() // src/app/providers.tsx export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()) const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ url: '/api/trpc', transformer: superjson, headers() { return { 'x-trpc-source': 'react' } }, }), ], }) ) return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </trpc.Provider> ) }

trpc.useQuery — Typsichere Queries

// Vollständige Autocompletion: trpc.user.getById.useQuery() function UserProfile({ userId }: { userId: string }) { const { data, isLoading, error } = trpc.user.getById.useQuery( { id: userId }, { staleTime: 5 * 60 * 1000, // 5 Minuten frisch retry: 2, enabled: !!userId, select: (data) => ({ // Daten transformieren (typsicher!) displayName: data.name.split(' ')[0], initials: data.name.slice(0, 2).toUpperCase(), }), } ) if (isLoading) return <Skeleton /> if (error) return <ErrorBoundary code={error.data?.code} /> return <div>{data.displayName}</div> // data ist UserProfile-Typ }

trpc.useMutation & Cache-Invalidation

function CreateUserForm() { const utils = trpc.useUtils() const createUser = trpc.user.create.useMutation({ async onMutate(newUser) { // Optimistic Update: UI sofort aktualisieren await utils.user.list.cancel() const prev = utils.user.list.getData() utils.user.list.setData(undefined, old => [...(old ?? []), newUser]) return { prev } }, onError(err, _newUser, ctx) { // Rollback bei Fehler utils.user.list.setData(undefined, ctx?.prev) }, onSettled() { // Cache immer nach Mutation neu laden utils.user.list.invalidate() }, }) return ( <button onClick={() => createUser.mutate({ name: 'Max', email: 'max@example.com' })}> Erstellen </button> ) }

Infinite Queries & Pagination

// Server: Cursor-basierte Pagination list: publicProcedure .input(z.object({ cursor: z.string().optional(), limit: z.number().min(1).max(100).default(20), })) .query(async ({ input }) => { const items = await db.post.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, }) return { items: items.slice(0, input.limit), nextCursor: items.length > input.limit ? items[input.limit].id : undefined, } }) // Client: useInfiniteQuery const { data, fetchNextPage, hasNextPage } = trpc.post.list.useInfiniteQuery( { limit: 20 }, { getNextPageParam: (lastPage) => lastPage.nextCursor } )

5. Next.js App Router

Mit dem Next.js App Router ändert sich das tRPC-Setup etwas: Route Handlers ersetzen die alten Pages-API-Routes, und Server Components können tRPC direkt über einen Caller aufrufen — ohne HTTP-Roundtrip.

Next.js 15 App Router

Route Handler: app/api/trpc/[trpc]/route.ts

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

createCaller für Server Components

Server Components

Direkte tRPC-Aufrufe ohne HTTP

createCallerFactory in Next.js Server Components: kein fetch, kein Netzwerk-Overhead — direkter Funktionsaufruf mit vollem Type-Safety.

// src/server/caller.ts import { createCallerFactory } from '@trpc/server' import { appRouter } from './routers/_app' import { createContext } from './context' const createCaller = createCallerFactory(appRouter) export async function getServerCaller() { const ctx = await createContext({ req: new Request('http://internal') }) return createCaller(ctx) } // Verwendung in Server Component — kein Client nötig! // app/dashboard/page.tsx export default async function DashboardPage() { const caller = await getServerCaller() const users = await caller.user.list() // direkt, kein fetch const metrics = await caller.project.getMetrics() return <Dashboard users={users} metrics={metrics} /> }

Prefetching mit dehydrate/hydrate

// Server Component prefetcht, Client Component nutzt gecachte Daten import { createHydrationHelpers } from '@trpc/react-query/rsc' export async function UserListPage() { const { trpc, HydrateClient } = await createHydrationHelpers() // Prefetch auf Server void trpc.user.list.prefetch() return ( <HydrateClient> <UserListClient /> {/* nutzt gecachte Daten, kein Refetch */} </HydrateClient> ) } // Client Component 'use client' function UserListClient() { // data ist sofort verfügbar — kein Loading State! const { data } = trpc.user.list.useSuspenseQuery() return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul> }
Ansatz Type Safety Code-Gen DX
tRPC ✅ Vollständig ✅ Nicht nötig ✅ Exzellent
REST + OpenAPI-Codegen ✅ Gut ❌ Pflicht ⚠️ Mittel
GraphQL + Codegen ✅ Gut ❌ Pflicht ❌ Komplex
REST ohne Codegen ❌ Manuell ✅ Nicht nötig ❌ Fehleranfällig

6. Subscriptions & Production

tRPC unterstützt echte Real-Time-Kommunikation via WebSockets. Kombiniert mit observable auf dem Server und useSubscription auf dem Client entstehen typsichere Live-Updates.

Server: observable Subscription

// src/server/routers/realtime.ts import { observable } from '@trpc/server/observable' import EventEmitter from 'events' const ee = new EventEmitter() export const realtimeRouter = router({ onMessage: publicProcedure .input(z.object({ roomId: z.string() })) .subscription(({ input }) => { return observable<{ id: string; text: string; createdAt: Date }>((emit) => { const onMessage = (msg: ChatMessage) => { if (msg.roomId === input.roomId) { emit.next(msg) } } ee.on('message', onMessage) return () => ee.off('message', onMessage) // Cleanup }) }), })

Client: WebSocket Link & useSubscription

// Client mit WebSocket Link konfigurieren import { createWSClient, wsLink, splitLink } from '@trpc/client' const wsClient = createWSClient({ url: 'wss://your-app.com/api/trpc' }) const trpcClient = trpc.createClient({ links: [ splitLink({ // Subscriptions → WebSocket, Queries/Mutations → HTTP condition: (op) => op.type === 'subscription', true: wsLink({ client: wsClient, transformer: superjson }), false: httpBatchLink({ url: '/api/trpc', transformer: superjson }), }), ], }) // React Hook für Subscriptions function ChatRoom({ roomId }: { roomId: string }) { const [messages, setMessages] = useState<ChatMessage[]>([]) trpc.realtime.onMessage.useSubscription( { roomId }, { onData(msg) { setMessages(prev => [...prev, msg]) }, onError(err) { console.error('Subscription error:', err) }, } ) return <MessageList messages={messages} /> }

tRPC Panel für Development

Dev Tool

tRPC Panel

Automatisch generiertes API-Testing-UI direkt aus dem AppRouter — kein Postman, keine manuelle Dokumentation. Nur in Development aktivieren.

npm install trpc-panel // app/api/panel/route.ts (nur Development!) import { renderTrpcPanel } from 'trpc-panel' import { appRouter } from '~/server/routers/_app' export function GET() { if (process.env.NODE_ENV !== 'development') { return new Response('Not found', { status: 404 }) } return new Response( renderTrpcPanel(appRouter, { url: 'http://localhost:3000/api/trpc' }), { headers: { 'Content-Type': 'text/html' } } ) }

Production: Batching, Error Handling & OpenTelemetry

// Batching: Mehrere Queries in einem HTTP-Request zusammenfassen httpBatchLink({ url: '/api/trpc', transformer: superjson, maxURLLength: 2083, // GET-Requests bis zu dieser Länge }) // Globales Error-Handling const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { if (error?.data?.code === 'UNAUTHORIZED') return false return failureCount < 3 }, }, }, }) // OpenTelemetry: Jede Procedure automatisch tracen const withTelemetry = t.middleware(async ({ path, type, next }) => { return tracer.startActiveSpan(`trpc.${type}.${path}`, async (span) => { try { const result = await next() span.setStatus({ code: SpanStatusCode.OK }) return result } catch (err) { span.recordException(err) throw err } finally { span.end() } }) })
Produktions-Hinweis: tRPC Panel niemals in Production aktivieren — es exponiert alle Procedure-Namen und Input-Schemas. Nur hinter NODE_ENV === 'development' oder einer IP-Whitelist verwenden.

Fazit: Wann tRPC, wann REST?

tRPC ist ideal wenn Server und Client in TypeScript geschrieben sind und im selben Monorepo liegen. Der Vorteil: null Code-Gen-Overhead, vollständige Autocompletion bis in die Tiefen verschachtelter Response-Objekte. Für öffentliche APIs (mobile Apps, Drittanbieter) bleibt REST + OpenAPI die bessere Wahl.

tRPC-Projekte mit Claude Code aufbauen

Router, Procedures, Zod-Schemas, React Query Hooks — Claude Code generiert vollständige tRPC-Setups in Minuten. Teste es kostenlos in unserem Trial.

Kostenlosen Trial starten →