API & TypeScript

tRPC mit Claude Code: End-to-End TypeScript ohne API-Schema 2026

6. Mai 2026 11 min Lesezeit von Agentic Movers

tRPC hat die Art, wie TypeScript-Entwickler APIs bauen, fundamental verändert. Keine Schema-Definitionen, kein Code-Generation-Schritt, keine Laufzeit-Fehler durch Type-Mismatches zwischen Frontend und Backend — stattdessen vollständige End-to-End-Typsicherheit direkt aus dem TypeScript-Typsystem. In diesem Guide zeigen wir, wie Claude Code dabei hilft, tRPC-Anwendungen schneller und sicherer zu entwickeln.

Das klassische Problem bei REST-APIs: Das Backend gibt ein Objekt zurück, das Frontend erwartet ein anderes Format, und der Fehler taucht erst zur Laufzeit auf — oder schlimmer: erst beim Nutzer. GraphQL löst das teilweise, aber mit hohem Setup-Aufwand. tRPC geht einen anderen Weg: Da Client und Server beide TypeScript sprechen, kann der Router-Typ direkt als Client-Typisierung dienen. Claude Code versteht dieses Muster nativ und generiert korrekte, konsistente tRPC-Implementierungen auf Anhieb.

Was du in diesem Artikel lernst:
  • tRPC v11 Setup mit initTRPC und Router-Definition
  • Input- und Output-Validierung mit Zod
  • Context-Erstellung und typsichere Middleware-Chains
  • React-Integration mit TanStack Query
  • Next.js App Router: Server Components und Server Actions
  • Real-time Subscriptions via WebSockets

1. tRPC Setup & Router-Definition

Der Einstieg in tRPC beginnt mit initTRPC — dem zentralen Konfigurationspunkt für den gesamten Stack. Hier wird der Kontext-Typ definiert, Fehlerformatierung festgelegt und die Basis-Instanz für alle weiteren Elemente erstellt. Claude Code generiert diesen Boilerplate zuverlässig mit allen nötigen Exporten.

Router  v11

server/trpc.ts — Basis-Setup

// server/trpc.ts — tRPC Instanz initialisieren import { initTRPC } from '@trpc/server'; import { ZodError } from 'zod'; import { type Context } from './context'; const t = initTRPC.context<Context>().create({ 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 middleware = t.middleware; export const mergeRouters = t.mergeRouters;

Mit der tRPC-Instanz lassen sich Router modular aufbauen. Jeder Sub-Router deckt eine Domäne ab — Users, Posts, Auth — und wird im App-Router zusammengeführt. Der exportierte AppRouter-Typ ist das Herzstück der End-to-End-Typsicherheit.

Router

server/routers/user.router.ts

import { z } from 'zod'; import { router, publicProcedure } from '../trpc'; import { db } from '../db'; export const userRouter = router({ list: publicProcedure .input( z.object({ limit: z.number().min(1).max(100).default(20), cursor: z.string().optional(), }) ) .query(async ({ input }) => { const { limit, cursor } = input; const users = await db.user.findMany({ take: limit + 1, cursor: cursor ? { id: cursor } : undefined, orderBy: { createdAt: 'desc' }, }); let nextCursor: string | undefined; if (users.length > limit) { const nextItem = users.pop(); nextCursor = nextItem!.id; } return { users, nextCursor }; }), byId: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) => { return db.user.findUniqueOrThrow({ where: { id: input.id } }); }), create: publicProcedure .input( z.object({ name: z.string().min(2).max(100), email: z.string().email(), role: z.enum(['USER', 'ADMIN']).default('USER'), }) ) .mutation(async ({ input }) => { return db.user.create({ data: input }); }), update: publicProcedure .input( z.object({ id: z.string().uuid(), data: z.object({ name: z.string().min(2).max(100).optional(), email: z.string().email().optional(), }), }) ) .mutation(async ({ input }) => { return db.user.update({ where: { id: input.id }, data: input.data, }); }), delete: publicProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ input }) => { await db.user.delete({ where: { id: input.id } }); return { success: true }; }), });
Router

server/routers/index.ts — App Router zusammenführen

import { router } from '../trpc'; import { userRouter } from './user.router'; import { postRouter } from './post.router'; import { authRouter } from './auth.router'; export const appRouter = router({ users: userRouter, posts: postRouter, auth: authRouter, }); // Den Typ des App-Routers exportieren — das ist der Kern der E2E-Typsicherheit export type AppRouter = typeof appRouter;

Standalone HTTP Server (ohne Framework)

import { createHTTPServer } from '@trpc/server/adapters/standalone'; import { appRouter } from './routers'; import { createContext } from './context'; const server = createHTTPServer({ router: appRouter, createContext, middleware: (req, res, next) => { res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); if (req.method === 'OPTIONS') { res.end(); return; } next(); }, }); server.listen(4000); console.log('tRPC Server läuft auf http://localhost:4000');
Claude Code Prompt-Tipp: "Erstelle einen tRPC-Router für [Domain] mit CRUD-Operationen, Cursor-basierter Pagination, Zod-Validierung und Prisma-Integration. Exportiere den AppRouter-Typ." — Claude Code generiert vollständigen, produktionsreifen Code inklusive Error Handling.

2. Input-Validierung mit Zod

Zod ist die bevorzugte Validierungsbibliothek im tRPC-Ökosystem — und das aus gutem Grund. Zod-Schemas definieren nicht nur die Validierungsregeln zur Laufzeit, sondern leiten gleichzeitig TypeScript-Typen ab. Das bedeutet: Ein Schema ist gleichzeitig Dokumentation, Validierung und Typdefinition. Claude Code nutzt Zod-Patterns konsequent und erzeugt dabei idiomatischen, wartbaren Code.

Zod  Schemas

Komplexe Zod-Schemas für tRPC Procedures

import { z } from 'zod'; // Enum-basierte Schemas mit Validierung const UserRoleSchema = z.enum(['GUEST', 'USER', 'MODERATOR', 'ADMIN']); type UserRole = z.infer<typeof UserRoleSchema>; // Verschachtelte Objekte mit optionalen Feldern const CreatePostSchema = z.object({ title: z.string() .min(5, 'Titel muss mindestens 5 Zeichen haben') .max(200, 'Titel darf maximal 200 Zeichen haben') .trim(), content: z.string().min(20).max(50000), tags: z.array(z.string().toLowerCase().trim()).max(10).default([]), publishedAt: z.coerce.date().optional(), metadata: z.object({ seoTitle: z.string().max(60).optional(), seoDescription: z.string().max(160).optional(), ogImage: z.string().url().optional(), }).optional(), status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']).default('DRAFT'), }); // superRefine für Cross-Field-Validierung const DateRangeSchema = z.object({ from: z.coerce.date(), to: z.coerce.date(), includeArchived: z.boolean().default(false), }).superRefine((data, ctx) => { if (data.to <= data.from) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Das Enddatum muss nach dem Startdatum liegen', path: ['to'], }); } const diffMs = data.to.getTime() - data.from.getTime(); const diffDays = diffMs / (1000 * 60 * 60 * 24); if (diffDays > 365) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Zeitraum darf maximal 365 Tage umfassen', path: ['to'], }); } }); // Output-Schema: Typdefinition für den Rückgabewert const PostResponseSchema = z.object({ id: z.string().uuid(), title: z.string(), content: z.string(), tags: z.array(z.string()), status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']), author: z.object({ id: z.string(), name: z.string(), avatarUrl: z.string().url().nullable(), }), createdAt: z.date(), updatedAt: z.date(), _count: z.object({ comments: z.number(), likes: z.number() }), }); // Typ aus Schema ableiten type CreatePostInput = z.infer<typeof CreatePostSchema>; type PostResponse = z.infer<typeof PostResponseSchema>;

Procedure mit Input- und Output-Schema

export const postRouter = router({ create: protectedProcedure .input(CreatePostSchema) .output(PostResponseSchema) // Output wird zur Laufzeit validiert! .mutation(async ({ ctx, input }) => { const post = await db.post.create({ data: { ...input, authorId: ctx.user.id, }, include: { author: { select: { id: true, name: true, avatarUrl: true } }, _count: { select: { comments: true, likes: true } }, }, }); return post; }), search: publicProcedure .input(z.object({ query: z.string().min(2).max(100), dateRange: DateRangeSchema.optional(), tags: z.array(z.string()).max(5).optional(), sortBy: z.enum(['relevance', 'date', 'popularity']).default('relevance'), })) .query(async ({ input }) => { // Volltextsuche mit Filtern return db.post.findMany({ where: { OR: [ { title: { contains: input.query, mode: 'insensitive' } }, { content: { contains: input.query, mode: 'insensitive' } }, ], tags: input.tags ? { hasEvery: input.tags } : undefined, publishedAt: input.dateRange ? { gte: input.dateRange.from, lte: input.dateRange.to, } : undefined, status: 'PUBLISHED', }, orderBy: input.sortBy === 'date' ? { publishedAt: 'desc' } : { likes: { _count: 'desc' } }, take: 20, }); }), });
Validierungsansatz Typsicherheit Laufzeit-Check Code-Gen nötig
tRPC + Zod Vollständig E2E Ja (automatisch) Nein
REST + OpenAPI Manuell / Generator Optional Ja
GraphQL + Codegen Vollständig Resolver-seitig Ja
REST ohne Schema Keine Nein Nein
Zod-Pattern für Claude Code: Definiere Schemas immer in separaten Dateien (schemas/) und importiere sie sowohl im Router als auch im Frontend-Code. Claude Code versteht dieses Muster und hält die Typen automatisch konsistent.

3. Context & Middleware

Der tRPC-Kontext ist der gemeinsame Datenraum, der bei jedem Request erstellt wird und durch die gesamte Procedure-Chain fließt. Er enthält typischerweise den authentifizierten Nutzer, Datenbankverbindungen und Request-Metadaten. Middleware erweitert diesen Kontext und kann Procedures bedingungsabhängig blockieren oder anreichern.

Context

server/context.ts — Request-Kontext erstellen

import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; import { getServerSession } from 'next-auth'; import { authOptions } from './auth'; import { db } from './db'; export async function createContext({ req, res }: CreateNextContextOptions) { // Session aus Header oder Cookie auslesen const session = await getServerSession(req, res, authOptions); // Request-spezifische Metadaten const requestId = req.headers['x-request-id'] as string | undefined; const userAgent = req.headers['user-agent']; const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ?? req.socket.remoteAddress ?? 'unknown'; return { db, session, user: session?.user ?? null, meta: { requestId, userAgent, ip, timestamp: new Date() }, }; } export type Context = Awaited<ReturnType<typeof createContext>>;
Middleware

server/middleware.ts — Auth & Logging Middleware

import { TRPCError } from '@trpc/server'; import { middleware, publicProcedure } from './trpc'; // Auth-Middleware: blockiert unauthentifizierte Requests const isAuthenticated = middleware(({ ctx, next }) => { if (!ctx.user || !ctx.session) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Du musst angemeldet sein, um diese Aktion durchzuführen.', }); } // Kontext mit typsicherem User anreichern return next({ ctx: { ...ctx, user: ctx.user, // non-nullable nach diesem Punkt session: ctx.session, }, }); }); // Admin-Middleware: nur für ADMIN-Role const isAdmin = middleware(({ ctx, next }) => { if (ctx.user?.role !== 'ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Nur Administratoren können diese Aktion durchführen.', }); } return next({ ctx }); }); // Logging-Middleware: Performance-Tracking const withLogging = middleware(async ({ path, type, next, ctx }) => { const start = Date.now(); const result = await next(); const durationMs = Date.now() - start; console.log(JSON.stringify({ timestamp: new Date().toISOString(), path, type, durationMs, userId: ctx.user?.id ?? 'anonymous', requestId: ctx.meta.requestId, ok: result.ok, error: !result.ok ? result.error.code : undefined, })); return result; }); // Rate-Limiting Middleware const withRateLimit = middleware(async ({ ctx, next }) => { const key = `ratelimit:${ctx.meta.ip}`; const count = await redis.incr(key); if (count === 1) await redis.expire(key, 60); // 1 Minute Window if (count > 100) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'Zu viele Anfragen. Bitte warte eine Minute.', }); } return next(); }); // Zusammengesetzte Procedures exportieren export const protectedProcedure = publicProcedure .use(withLogging) .use(withRateLimit) .use(isAuthenticated); export const adminProcedure = protectedProcedure.use(isAdmin);

Meta-Typen für Middleware-Konfiguration

// Meta ermöglicht Procedure-spezifische Konfiguration interface TRPCMeta { authRequired?: boolean; rateLimit?: { max: number; windowMs: number }; cache?: { ttlSeconds: number }; } const t = initTRPC .context<Context>() .meta<TRPCMeta>() .create(); // Procedure mit Meta-Konfiguration export const dashboardData = publicProcedure .meta({ cache: { ttlSeconds: 60 }, authRequired: true }) .query(async ({ ctx }) => { return getDashboardData(ctx.user!.id); }); // Cache-Middleware liest Meta aus const withCache = middleware(async ({ meta, next, path }) => { if (!meta?.cache) return next(); const cacheKey = `trpc-cache:${path}`; const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached); const result = await next(); if (result.ok) { await redis.setex(cacheKey, meta.cache.ttlSeconds, JSON.stringify(result)); } return result; });

4. React-Integration mit TanStack Query

tRPC und TanStack Query (ehemals React Query) sind die perfekte Kombination: tRPC übernimmt die Typsicherheit der API-Kommunikation, TanStack Query kümmert sich um Caching, Refetching, Pagination und Mutationen. Das tRPC-React-Paket generiert typsichere Hooks, die sich genauso anfühlen wie normale TanStack-Query-Hooks — nur vollständig typisiert.

React  TanStack Query

lib/trpc.ts — Client-Setup

import { createTRPCReact } from '@trpc/react-query'; import { httpBatchLink, loggerLink } from '@trpc/client'; import { type AppRouter } from '../server/routers'; import superjson from 'superjson'; // Typisierter tRPC-Client für React export const trpc = createTRPCReact<AppRouter>(); export function createTRPCClient() { return 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() { return { 'x-trpc-source': 'react', }; }, }), ], }); } 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}`; }
React

providers/TRPCProvider.tsx — App-weiter Provider

'use client'; import { useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { trpc, createTRPCClient } from '@/lib/trpc'; export function TRPCClientProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 Minute gcTime: 5 * 60 * 1000, // 5 Minuten (ehemals cacheTime) retry: (failureCount, error: any) => { if (error?.data?.code === 'UNAUTHORIZED') return false; return failureCount < 3; }, refetchOnWindowFocus: 'always', }, mutations: { onError: (error: any) => { console.error('Mutation Error:', error.message); }, }, }, })); const [trpcClient] = useState(() => createTRPCClient()); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> {children} {process.env.NODE_ENV === 'development' && ( <ReactQueryDevtools initialIsOpen={false} /> )} </QueryClientProvider> </trpc.Provider> ); }

components/UserList.tsx — Vollständige React-Komponente

'use client'; import { useState } from 'react'; import { trpc } from '@/lib/trpc'; export function UserList() { const [cursor, setCursor] = useState<string | undefined>(undefined); // Typisierter Query-Hook — Input wird vom Router-Typ geleitet const { data, isLoading, error, isFetching } = trpc.users.list.useQuery( { limit: 20, cursor }, { keepPreviousData: true, // Daten beim Paginieren behalten select: (data) => ({ ...data, users: data.users.map(u => ({ ...u, displayName: u.name.split(' ')[0] })), }), } ); // Typisierte Mutation const utils = trpc.useUtils(); const deleteUser = trpc.users.delete.useMutation({ onSuccess: () => { // Cache invalidieren → automatisches Refetch utils.users.list.invalidate(); }, onError: (error) => { alert(`Fehler: ${error.message}`); }, }); const createUser = trpc.users.create.useMutation({ onMutate: async (newUser) => { // Optimistisches Update: UI sofort aktualisieren await utils.users.list.cancel(); const previousData = utils.users.list.getData({ limit: 20 }); utils.users.list.setData({ limit: 20 }, (old) => ({ users: [{ id: 'temp', ...newUser, createdAt: new Date() }, ...(old?.users ?? [])], nextCursor: old?.nextCursor, })); return { previousData }; }, onError: (_, __, context) => { utils.users.list.setData({ limit: 20 }, context?.previousData); }, onSettled: () => utils.users.list.invalidate(), }); if (isLoading) return <div>Lade Benutzer...</div>; if (error) return <div>Fehler: {error.message}</div>; return ( <div> {isFetching && <span>Aktualisiert...</span>} {data?.users.map(user => ( <div key={user.id}> <strong>{user.displayName}</strong> ({user.email}) <button onClick={() => deleteUser.mutate({ id: user.id })} disabled={deleteUser.isPending} > Löschen </button> </div> ))} {data?.nextCursor && ( <button onClick={() => setCursor(data.nextCursor)}> Mehr laden </button> )} </div> ); }
Optimistic Updates mit tRPC: Claude Code kann vollständige Optimistic-Update-Logik generieren, inklusive Rollback bei Fehler. Nutze den Prompt: "Implementiere optimistische Updates für [Mutation] mit automatischem Rollback via TanStack Query und tRPC."

5. Next.js App Router Integration

Mit dem Next.js App Router bringt tRPC eine neue Ebene der Flexibilität: Queries können direkt in Server Components ohne Client-Overhead ausgeführt werden. Mutationen laufen als Server Actions. Der gleiche tRPC-Router bedient sowohl Client-seitige React-Queries als auch Server-seitige Aufrufe — ohne Code-Duplikation.

Next.js  App Router

app/api/trpc/[trpc]/route.ts — API Handler

import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { type NextRequest } from 'next/server'; import { appRouter } from '@/server/routers'; import { createContext } from '@/server/context'; const handler = (req: NextRequest) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: () => createContext({ req }), onError: process.env.NODE_ENV === 'development' ? ({ path, error }) => { console.error(`tRPC Error auf ${path ?? 'unknown'}:`, error`); } : undefined, }); export const { GET, POST } = { GET: handler, POST: handler };

lib/trpc-server.ts — Server-seitiger Caller

import { createCallerFactory } from '@trpc/server'; import { appRouter } from '@/server/routers'; import { createContext } from '@/server/context'; import { cache } from 'react'; import { headers } from 'next/headers'; // Caller-Factory erstellen const createCaller = createCallerFactory(appRouter); // React cache() für Deduplication in einer Render-Runde export const createServerCaller = cache(async () => { const headersList = headers(); const ctx = await createContext({ req: new Request('http://internal', { headers: Object.fromEntries(headersList.entries()), }), }); return createCaller(ctx); });

app/dashboard/page.tsx — Server Component mit tRPC

import { createServerCaller } from '@/lib/trpc-server'; import { Suspense } from 'react'; import { UserCard } from '@/components/UserCard'; // Server Component — kein 'use client', läuft auf dem Server export default async function DashboardPage() { const caller = await createServerCaller(); // Parallele Queries ohne Client-Overhead const [users, stats] = await Promise.all([ caller.users.list({ limit: 10 }), caller.posts.stats(), ]); return ( <main> <h1>Dashboard</h1> <div className="stats"> <p>Gesamt Posts: {stats.totalPosts}</p> <p>Veröffentlicht: {stats.publishedPosts}</p> </div> <Suspense fallback={<div>Lade Benutzer...</div>}> <ul> {users.users.map(user => ( <li key={user.id}><UserCard user={user} /></li> ))} </ul> </Suspense> </main> ); }

app/actions/user.ts — Server Actions mit tRPC

'use server'; import { revalidatePath, revalidateTag } from 'next/cache'; import { createServerCaller } from '@/lib/trpc-server'; import { CreatePostSchema } from '@/server/schemas'; export async function createPostAction(formData: FormData) { const rawData = { title: formData.get('title'), content: formData.get('content'), tags: formData.getAll('tags'), }; // Zod-Validierung im Server Action const parsed = CreatePostSchema.safeParse(rawData); if (!parsed.success) { return { error: parsed.error.flatten() }; } const caller = await createServerCaller(); const post = await caller.posts.create(parsed.data); // Next.js Cache nach Mutation invalidieren revalidatePath('/blog'); revalidateTag('posts'); return { success: true, post }; }
Hydration-Tipp: Kombiniere Server Component (initiales Laden) mit Client Component (Interaktivität): Der Server lädt Daten ohne Client-Roundtrip, der Client übernimmt danach Mutations und Refetches. Claude Code generiert dieses hybride Pattern auf Anfrage vollständig.

6. Subscriptions & WebSockets

tRPC unterstützt nicht nur Request-Response-Patterns — mit Subscriptions können Server-seitige Events direkt in React-Komponenten gestreamt werden. Das ist ideal für Notifications, Live-Updates, Chat-Anwendungen oder Echtzeit-Dashboards. WebSockets werden über einen speziellen Link konfiguriert.

WebSocket  Subscriptions

server/routers/notification.router.ts

import { observable } from '@trpc/server/observable'; import { EventEmitter } from 'events'; import { z } from 'zod'; import { router, protectedProcedure } from '../trpc'; // Globaler EventEmitter als einfacher Pub/Sub-Mechanismus const ee = new EventEmitter(); ee.setMaxListeners(1000); // Viele simultane Verbindungen erlauben export interface Notification { id: string; userId: string; type: 'LIKE' | 'COMMENT' | 'FOLLOW' | 'MENTION' | 'SYSTEM'; title: string; body: string; href?: string; readAt?: Date; createdAt: Date; } export const notificationRouter = router({ // Subscription: Echtzeit-Notifications für eingeloggten User onNotification: protectedProcedure .input(z.object({ types: z.array(z.enum(['LIKE', 'COMMENT', 'FOLLOW', 'MENTION', 'SYSTEM'])) .optional(), })) .subscription(({ ctx, input }) => { return observable<Notification>((emit) => { const eventKey = `notification:${ctx.user.id}`; const handler = (notification: Notification) => { // Nur passende Typen senden wenn Filter gesetzt if (input.types && !input.types.includes(notification.type)) return; emit.next(notification); }; ee.on(eventKey, handler); // Cleanup wenn Client disconnectet return () => { ee.off(eventKey, handler); }; }); }), // Subscription: Live Activity Feed onActivityFeed: protectedProcedure .subscription(({ ctx }) => { return observable<{ type: string; data: unknown; ts: Date }>((emit) => { const handler = (event: any) => emit.next(event); ee.on(`feed:${ctx.user.id}`, handler); return () => ee.off(`feed:${ctx.user.id}`, handler); }); }), // Mutation: Notification als gelesen markieren markAsRead: protectedProcedure .input(z.object({ notificationId: z.string() })) .mutation(async ({ ctx, input }) => { return db.notification.update({ where: { id: input.notificationId, userId: ctx.user.id }, data: { readAt: new Date() }, }); }), }); // Helper: Notification senden (aus anderen Teilen des Backends aufrufbar) export function sendNotification(notification: Notification) { ee.emit(`notification:${notification.userId}`, notification); }

lib/trpc-ws.ts — WebSocket-Client mit splitLink

import { createTRPCReact } from '@trpc/react-query'; import { httpBatchLink, splitLink, wsLink, createWSClient } from '@trpc/client'; import { type AppRouter } from '../server/routers'; import superjson from 'superjson'; export const trpc = createTRPCReact<AppRouter>(); function getWebSocketUrl() { const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; return `${wsProtocol}://${window.location.host}/api/trpc-ws`; } export function createTRPCClientWithWS() { const wsClient = createWSClient({ url: getWebSocketUrl }); return trpc.createClient({ links: [ // splitLink: WebSocket für Subscriptions, HTTP für alles andere splitLink({ condition: (op) => op.type === 'subscription', true: wsLink({ client: wsClient, transformer: superjson }), false: httpBatchLink({ url: '/api/trpc', transformer: superjson, }), }), ], }); }

components/NotificationBell.tsx — useSubscription in React

'use client'; import { useState } from 'react'; import { trpc } from '@/lib/trpc-ws'; import { type Notification } from '@/server/routers/notification.router'; export function NotificationBell() { const [notifications, setNotifications] = useState<Notification[]>([]); const [isConnected, setIsConnected] = useState(false); // Subscription Hook — automatische Verbindung beim Mount trpc.notifications.onNotification.useSubscription( { types: ['LIKE', 'COMMENT', 'MENTION'] }, { onStarted: () => setIsConnected(true), onData: (notification) => { setNotifications(prev => [notification, ...prev.slice(0, 49)]); // Browser Notification API if (Notification.permission === 'granted') { new Notification(notification.title, { body: notification.body, icon: '/icon-192.png', }); } }, onError: (err) => { console.error('Subscription Error:', err); setIsConnected(false); }, } ); const utils = trpc.useUtils(); const markAsRead = trpc.notifications.markAsRead.useMutation({ onSuccess: (_, variables) => { setNotifications(prev => prev.map(n => n.id === variables.notificationId ? { ...n, readAt: new Date() } : n) ); }, }); const unreadCount = notifications.filter(n => !n.readAt).length; return ( <div className="notification-bell"> <button aria-label="Benachrichtigungen"> 🔔 {unreadCount > 0 && <span className="badge">{unreadCount}</span>} </button> <div className="notification-status"> <span style={{ color: isConnected ? 'green' : 'red' }}> {isConnected ? '● Live' : '○ Verbinde...'} </span> </div> <ul> {notifications.map(n => ( <li key={n.id} className={n.readAt ? 'read' : 'unread'}> <strong>{n.title}</strong> <p>{n.body}</p> {!n.readAt && ( <button onClick={() => markAsRead.mutate({ notificationId: n.id })}> Als gelesen markieren </button> )} </li> ))} </ul> </div> ); }

WebSocket Server für Next.js (separater Prozess)

// server/ws-server.ts — Separater WebSocket-Server import { applyWSSHandler } from '@trpc/server/adapters/ws'; import { WebSocketServer } from 'ws'; import { appRouter } from './routers'; import { createContext } from './context'; const wss = new WebSocketServer({ port: 3001 }); const handler = applyWSSHandler({ wss, router: appRouter, createContext, keepAlive: { enabled: true, pingMs: 30_000, // Ping alle 30 Sekunden pongWaitMs: 5_000, }, }); wss.on('connection', (ws) => { console.log(`WebSocket-Verbindung: ${wss.clients.size} aktiv`); ws.once('close', () => { console.log(`Verbindung getrennt: ${wss.clients.size} aktiv`); }); }); process.on('SIGTERM', () => { handler.broadcastReconnectNotification(); wss.close(); }); console.log('WebSocket Server läuft auf ws://localhost:3001');
Production-Hinweis: Für produktive Deployments auf serverlosem Hosting (Vercel) sind Subscriptions via Server-sent Events (SSE) statt WebSockets empfohlen. tRPC v11 unterstützt SSE nativ. Claude Code generiert beide Varianten — gib einfach an, welche Deployment-Umgebung du nutzt.

Fazit: tRPC + Claude Code = Schnellere API-Entwicklung

tRPC löst eines der hartnäckigsten Probleme in der Full-Stack-TypeScript-Entwicklung: die Typ-Diskrepanz zwischen Frontend und Backend. Kein Schema-Sharing über separate Packages, kein Code-Generation-Schritt, keine API-Dokumentation die aus dem Sync gerät — stattdessen direkte, automatische Typsicherheit durch das TypeScript-Typsystem.

Claude Code versteht die tRPC-Architektur auf einem tiefen Niveau und generiert dabei idiomatischen Code:

Das Ergebnis: Statt Stunden mit Boilerplate, Schema-Synchronisation und Typ-Problemen zu verbringen, konzentriert man sich auf die eigentliche Business-Logik. tRPC mit Claude Code ist 2026 der schnellste Weg zu einer typ-sicheren, wartbaren Full-Stack-TypeScript-Anwendung.

tRPC-Modul im Kurs

Im Claude Code Mastery Kurs: vollständiges tRPC-Modul mit Next.js-Integration, Middleware, Subscriptions und End-to-End Type Safety für moderne Web-Anwendungen.

14 Tage kostenlos testen →