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:
- Router-Strukturen mit korrekten Export-Typen für E2E-Typsicherheit
- Zod-Schemas die Input, Output und TypeScript-Typen in einem definieren
- Middleware-Chains mit Context-Enrichment und korrekter Typen-Narrowing
- React-Hooks mit optimistischen Updates, Pagination und Cache-Invalidierung
- Next.js App Router Integration für Server Components und Server Actions
- WebSocket-Subscriptions mit korrektem Cleanup und Reconnect-Logik
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 →