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 →