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 verwenden: Full-Stack TypeScript, Next.js-Monorepo, internes API, Team mit TS-Erfahrung
- REST bevorzugen: Öffentliche API, polyglotte Clients (iOS, Android, Python), externe Partner
- Hybrid möglich: tRPC intern + OpenAPI extern — beide können parallel laufen
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 →