Typsichere tRPC-API mit Claude Code bauen
Baue eine typsichere tRPC-API mit Claude Code: Next.js, Zod, Rollen, Cache-Invalidierung und Review-Prompts.
Warum tRPC und Claude Code zusammenpassen
tRPC macht den TypeScript-Router auf der Serverseite zur gemeinsamen API-Vertragsquelle. Der Client braucht keine separate OpenAPI-Datei und kein manuell gepflegtes SDK, sondern importiert den Typ AppRouter und erhält passende Eingaben, Rückgaben und Fehlerformen. Das ist besonders wertvoll, wenn UI und API in einem Repository schnell gemeinsam wachsen.
Claude Code ist nicht nur für Boilerplate nützlich. Der eigentliche Nutzen entsteht, wenn es die vorhandene Projektstruktur, Authentifizierung, Datenzugriffe, React-Query-Konventionen und Namensregeln lesen kann. Dann kann es Router, Procedures, Zod-Validierung, Autorisierung und Cache-Invalidierung so erzeugen, dass sie in das bestehende System passen. Trotzdem gilt: Typsicherheit ersetzt keine fachliche Prüfung. Eine Mutation kann korrekt typisiert sein und trotzdem Daten eines fremden Teams ändern, wenn die Berechtigungsprüfung fehlt.
Das Beispiel nutzt Next.js App Router und eine kleine Projektverwaltungs-API. Die Daten liegen in einem Map, damit der Code kopierbar bleibt. In einer echten Anwendung ersetzt du diesen Teil durch Prisma, Drizzle, Supabase oder eine vorhandene Datenquelle. Die Demo-Session muss vor Produktion ebenfalls durch echte Authentifizierung ersetzt werden.
Geeignete Use Cases
tRPC passt am besten, wenn Server und Client im gleichen TypeScript-System leben. Für öffentliche APIs mit externen Partnern kann OpenAPI besser sein. Für Admin-Oberflächen, interne Tools, Formulare und dünne BFF-Schichten ist tRPC oft sehr produktiv.
| Use Case | Vorteil von tRPC | Aufgabe für Claude Code | Risiko |
|---|---|---|---|
| Admin-CRUD | Eingaben und Rückgaben für Listen, Erstellen, Ändern, Löschen bleiben verbunden | Router, Zod-Schema, Mutations, Invalidierung | Buttons verstecken statt serverseitig prüfen |
| Interne Tools | Prozessänderungen können schnell mit UI und API ausgeliefert werden | Procedures aus bestehenden Modellen ableiten | Zu großer context |
| Formulare | Runtime-Validierung für E-Mail, Länge und enum | Fehlermeldungen und Schutz vor Doppelversand | Nur TypeScript vertrauen |
| Dünner BFF | Exakt die Datenform für die Seite liefern | Externe API-Antworten mappen | Cache-Regeln unklar lassen |
flowchart LR
UI["React component"]
Client["tRPC React client"]
Router["AppRouter"]
Procedure["protected/admin procedure"]
Zod["Zod validation"]
Store["DB or store"]
Review["Claude Code review"]
UI --> Client --> Router --> Procedure --> Zod --> Store
Router --> Review
Review --> Procedure
Installation und Struktur
Installiere zuerst die Pakete. Falls React Query oder Zod bereits vorhanden sind, gleiche die Versionen ab, bevor Claude Code Code erzeugt.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
Die folgende Struktur trennt Servercode, HTTP-Adapter und React-Provider klar.
src/
app/api/trpc/[trpc]/route.ts
app/projects/project-list.tsx
server/trpc.ts
server/routers/_app.ts
server/routers/project.ts
trpc/client.tsx
Gib Claude Code diese Grenze ausdrücklich mit: Serverdateien importieren keine Client Components, Client Components greifen nicht direkt auf den Store zu, und gemeinsame Typen kommen aus dem tRPC-Router statt aus parallelen DTO-Dateien.
Context und Procedures
Der context enthält request-bezogene Informationen für jede Procedure. Halte ihn klein: Session, Team, Rolle und Datenzugriff reichen meistens. Der folgende Code nutzt Header nur als lokale Demo.
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
type Role = "admin" | "member";
type Session = { userId: string; teamId: string; role: Role };
export type Context = { session: Session | null };
export async function createContext({
headers,
}: {
headers: Headers;
}): Promise<Context> {
const roleHeader = headers.get("x-user-role");
const role: Role =
roleHeader === "admin"
? "admin"
: roleHeader === "member"
? "member"
: process.env.NODE_ENV === "production"
? "member"
: "admin";
return {
session: {
userId: headers.get("x-user-id") ?? "demo-user",
teamId: headers.get("x-team-id") ?? "demo-team",
role,
},
};
}
const t = initTRPC.context<Context>().create({ transformer: superjson });
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Login is required." });
}
return next({ ctx: { session: ctx.session } });
});
export const protectedProcedure = t.procedure.use(requireUser);
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.session.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role is required." });
}
return next();
});
Die Trennung zwischen protectedProcedure und adminProcedure macht Reviews einfacher. Eine gefährliche Mutation mit dem falschen Procedure-Typ fällt sofort auf.
Router mit Zod-Runtime-Validierung
TypeScript prüft den Build, aber nicht fremdes JSON zur Laufzeit. Zod validiert Eingaben genau an der API-Grenze, bevor fachliche Operationen laufen.
// src/server/routers/project.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
type ProjectStatus = "todo" | "doing" | "done";
type Project = {
id: string;
teamId: string;
title: string;
ownerEmail: string;
status: ProjectStatus;
createdAt: string;
};
const projects = new Map<string, Project>();
const projectStatus = z.enum(["todo", "doing", "done"]);
const listProjectsInput = z
.object({
status: projectStatus.optional(),
query: z.string().trim().max(60).optional(),
limit: z.number().int().min(1).max(50).default(20),
})
.default({ limit: 20 });
const createProjectInput = z.object({
title: z.string().trim().min(2).max(80),
ownerEmail: z.string().email(),
});
export const projectRouter = createTRPCRouter({
list: protectedProcedure.input(listProjectsInput).query(({ ctx, input }) => {
return [...projects.values()]
.filter((project) => project.teamId === ctx.session.teamId)
.filter((project) => !input.status || project.status === input.status)
.filter((project) => !input.query || project.title.includes(input.query))
.slice(0, input.limit);
}),
create: adminProcedure.input(createProjectInput).mutation(({ ctx, input }) => {
const project: Project = {
id: crypto.randomUUID(),
teamId: ctx.session.teamId,
title: input.title,
ownerEmail: input.ownerEmail,
status: "todo",
createdAt: new Date().toISOString(),
};
projects.set(project.id, project);
return project;
}),
updateStatus: protectedProcedure
.input(z.object({ id: z.string().uuid(), status: projectStatus }))
.mutation(({ ctx, input }) => {
const project = projects.get(input.id);
if (!project || project.teamId !== ctx.session.teamId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const nextProject = { ...project, status: input.status };
projects.set(input.id, nextProject);
return nextProject;
}),
});
Die Prüfung von teamId ist hier entscheidend. Ein gültiger UUID-String beweist nicht, dass der aktuelle Benutzer diesen Datensatz bearbeiten darf.
App Router und React Client
Fasse den Router zusammen und stelle ihn als Route Handler bereit.
// src/server/routers/_app.ts
import { createTRPCRouter } from "../trpc";
import { projectRouter } from "./project";
export const appRouter = createTRPCRouter({
project: projectRouter,
});
export type AppRouter = typeof appRouter;
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };
// src/trpc/client.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState, type ReactNode } from "react";
import superjson from "superjson";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();
export function TRPCProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [httpBatchLink({ url: "/api/trpc" })],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
// src/app/projects/project-list.tsx
"use client";
import { useState } from "react";
import { trpc } from "@/trpc/client";
export function ProjectList() {
const utils = trpc.useUtils();
const [title, setTitle] = useState("");
const projects = trpc.project.list.useQuery({ limit: 20 });
const createProject = trpc.project.create.useMutation({
onSuccess: async () => {
setTitle("");
await utils.project.list.invalidate();
},
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
createProject.mutate({ title, ownerEmail: "owner@example.com" });
}}
>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
<button type="submit">Add</button>
<pre>{JSON.stringify(projects.data, null, 2)}</pre>
</form>
);
}
Nach einer erfolgreichen Mutation wird project.list invalidiert. Ohne diese Regel bleibt die UI oft sichtbar veraltet, obwohl die Serveroperation erfolgreich war.
Review-Prompt für Claude Code
Reviewe diese Next.js App Router + tRPC Implementierung.
Prüfe:
1. publicProcedure, protectedProcedure und adminProcedure korrekt verwendet.
2. Zod-Runtime-Validierung für alle externen Inputs.
3. teamId und userId vor Lesen und Schreiben geprüft.
4. Betroffene Queries nach Mutations invalidiert.
5. Router nach Domänen getrennt und nicht zu groß.
6. Context enthält nur request-bezogene Abhängigkeiten.
7. Keine geheimen Felder werden an den Client zurückgegeben.
Gib Schweregrad, Datei, Problem, Fix und empfohlene Tests als Tabelle aus.
Dieser Prompt passt in CLAUDE.md oder in ein Pull-Request-Template. Bei Änderungen an Berechtigungen sollte Claude Code zuerst nur Diff und Risikoanalyse liefern.
Typische Fehler
Erstens: ein aufgeblähter context. Je mehr dort landet, desto schwerer werden Tests. Zweitens: Autorisierung nur in der UI. Ein versteckter Button schützt keinen Endpoint. Drittens: TypeScript ohne Zod. Echte Requests müssen zur Laufzeit geprüft werden. Viertens: ein monolithischer Router. Nach wenigen Wochen sinkt die Review-Qualität. Fünftens: unklare Cache-Invalidierung. Eine Mutation kann Listen, Details, Zähler und Dashboards betreffen; diese Abhängigkeiten sollten vor dem Code notiert werden.
Offizielle Quellen und nächste Schritte
Nutze die tRPC-Dokumentation zu Routers, Procedures und React Query integration. Für Runtime-Validierung ist die Zod-Dokumentation relevant, für den HTTP-Einstieg die Next.js Route Handlers.
Als nächste interne Links passen der Claude Code Zod Validation Guide und die Claude Code TypeScript Tipps. Wenn dein Team ein bestehendes Admin-Tool oder internes System prüfen möchte, unterstützt Claude Code Training und Beratung bei Router-Schnitt, Rollenmodell, Review-Prompts und Tests.
Ergebnis aus dem Test
Im Prototyp einer Admin-Oberfläche war die wichtigste Entscheidung, protectedProcedure und adminProcedure vor den CRUD-Routern anzulegen. Claude Code konnte dadurch schnell markieren, welche Mutation strengere Rechte braucht. Zod-Schemas direkt neben den Procedures machten Formularänderungen leichter nachvollziehbar. Der Nachteil zeigte sich bei einem zu bequemen context: Tests wurden schwerfällig. Die bessere Variante hält den context klein und verschiebt Fachlogik in separate Funktionen.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.