Eine typsichere Full-Stack-API mit tRPC und Next.js bauen
Vollständiges Tutorial für durchgängig typsichere APIs mit tRPC und Next.js App Router: Router-Setup, Middleware, Validierung und Subscriptions.

End-to-End-Typsicherheit: Warum sie wichtig ist
Du definierst einen API-Endpunkt, der ein User-Objekt zurückgibt. Das Backend-Team fügt ein Feld hinzu. Das Frontend-Team weiß nichts davon. Die App stürzt um 2 Uhr nachts in der Produktion ab, weil jemand auf eine Property zugegriffen hat, die nicht mehr existiert.
Dieses Szenario ist die natürliche Folge von getrennt typisiertem Client- und Server-Code. REST-APIs haben keine Typverbindung zwischen dem Handler, der Daten produziert, und der Komponente, die sie konsumiert. GraphQL fügt Schema-Typen hinzu, braucht aber Codegenerierung, um die Lücke zu schließen. tRPC eliminiert die Lücke vollständig — deine Backend-Typen sind deine Frontend-Typen, ganz ohne Codegenerierung.
Dieses Tutorial baut eine Full-Stack-Anwendung mit tRPC und Next.js App Router und zeigt, wie Typsicherheit von Datenbankabfragen über API-Prozeduren bis zu React-Komponenten fließt.
Den tRPC-Router einrichten
Der Router ist die zentrale Abstraktion von tRPC. Er definiert Prozeduren (Queries, Mutations, Subscriptions) mit Eingabevalidierung und Middleware-Ketten.
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
import superjson from "superjson";
import { getServerSession } from "next-auth";
interface Context {
session: { user: { id: string; email: string; role: string } } | null;
db: typeof prisma;
}
export async function createContext(): Promise<Context> {
const session = await getServerSession();
return {
session,
db: prisma,
};
}
const t = initTRPC.context<Context>().create({
transformer: superjson, // Handles Date, Map, Set serialization
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof z.ZodError
? error.cause.flatten()
: null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;Die Context-Factory läuft bei jedem Request und stellt die Session und den Datenbank-Client bereit. Der superjson-Transformer behandelt Typen, die JSON nicht nativ darstellen kann — Date-Objekte, Maps, Sets und BigInts werden alle korrekt serialisiert und deserialisiert.
Middleware-Ketten für Authentifizierung und Autorisierung
tRPC-Middleware lässt sich sauber komponieren. Jede Middleware kann den Kontext verändern, Vorbedingungen prüfen oder mit einem Fehler vorzeitig abbrechen.
// src/server/middleware.ts
import { TRPCError } from "@trpc/server";
import { middleware, publicProcedure } from "./trpc";
const isAuthenticated = middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in",
});
}
return next({
ctx: {
...ctx,
session: ctx.session, // Narrowed type: session is non-null
},
});
});
const isAdmin = middleware(async ({ ctx, next }) => {
if (!ctx.session?.user || ctx.session.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}
return next({ ctx });
});
// Composable procedures with middleware stacking
export const protectedProcedure = publicProcedure.use(isAuthenticated);
export const adminProcedure = publicProcedure
.use(isAuthenticated)
.use(isAdmin);Nachdem isAuthenticated gelaufen ist, verengt sich der Kontext-Typ — nachgelagerte Prozeduren wissen, dass die Session nicht null ist. Das ist der Vorteil der Typsicherheit: Die Middleware verändert gleichzeitig das Laufzeitverhalten und die Compile-Zeit-Typen.
Domain-Router bauen
Jede Domain bekommt ihren eigenen Router mit Prozeduren für Queries und Mutations.
// src/server/routers/posts.ts
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import { protectedProcedure, adminProcedure } from "../middleware";
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10).max(50000),
tags: z.array(z.string()).min(1).max(10),
published: z.boolean().default(false),
});
const listPostsSchema = z.object({
cursor: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
tag: z.string().optional(),
});
export const postsRouter = router({
list: publicProcedure
.input(listPostsSchema)
.query(async ({ input, ctx }) => {
const posts = await ctx.db.post.findMany({
where: input.tag ? { tags: { has: input.tag } } : undefined,
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: "desc" },
include: { author: { select: { name: true, image: true } } },
});
let nextCursor: string | undefined;
if (posts.length > input.limit) {
const extra = posts.pop()!;
nextCursor = extra.id;
}
return { posts, nextCursor };
}),
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
include: {
author: { select: { name: true, image: true } },
comments: {
include: { author: { select: { name: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!post) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return post;
}),
create: protectedProcedure
.input(createPostSchema)
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({
data: {
...input,
authorId: ctx.session.user.id,
},
});
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (post.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return ctx.db.post.delete({ where: { id: input.id } });
}),
});Jede Eingabe wird an der Prozedurgrenze durch Zod-Schemas validiert. Ungültige Requests erreichen deine Datenbankabfragen nie. Die Typinformation fließt automatisch — keine manuellen Typdefinitionen für Request- oder Response-Formen.
Router mergen und den API-Handler erstellen
// src/server/routers/index.ts
import { router } from "../trpc";
import { postsRouter } from "./posts";
import { usersRouter } from "./users";
import { commentsRouter } from "./comments";
export const appRouter = router({
posts: postsRouter,
users: usersRouter,
comments: commentsRouter,
});
export type AppRouter = typeof appRouter;// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers";
import { createContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };Der Export des Typs AppRouter ist die Brücke. Der Client importiert diesen Typ — nicht die Implementierung — und bekommt vollständige Typinferenz für jede Prozedur.
Client-Setup mit React-Query-Integration
tRPC v11 integriert sich mit TanStack Query (React Query) für Caching, Refetching, optimistische Updates und Infinite Queries.
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers";
export const trpc = createTRPCReact<AppRouter>();// src/app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
import superjson from "superjson";
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Der httpBatchLink bündelt automatisch mehrere tRPC-Aufrufe aus demselben Render-Zyklus in einen einzigen HTTP-Request. Drei gleichzeitig gefeuerte useQuery-Hooks ergeben einen Netzwerk-Request, nicht drei.
Prozeduren in Komponenten konsumieren
// ❌ Bad: Untyped fetch with manual types
async function loadPosts() {
const res = await fetch("/api/posts?limit=20");
const data: any = await res.json(); // No type safety
return data.posts; // Might not exist
}// ✅ Good: Fully typed tRPC query
"use client";
import { trpc } from "@/lib/trpc";
function PostsList() {
const { data, isLoading, error, fetchNextPage, hasNextPage } =
trpc.posts.list.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
if (isLoading) return <PostsSkeleton />;
if (error) return <ErrorDisplay message={error.message} />;
const allPosts = data.pages.flatMap((page) => page.posts);
return (
<div>
{allPosts.map((post) => (
<PostCard
key={post.id}
title={post.title}
// TypeScript knows exactly what fields exist
author={post.author.name}
createdAt={post.createdAt} // Date object, not string
/>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load more</button>
)}
</div>
);
}Hover in deinem Editor über post — TypeScript kennt die exakte Form, inklusive verschachtelter Relationen. Benenne ein Feld im Prisma-Schema um, und der Compiler zeigt dir jede Komponente, die angepasst werden muss. Keine veralteten Typen, keine Überraschungen zur Laufzeit.
Optimistische Updates für Mutations
function CreateComment({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const createComment = trpc.comments.create.useMutation({
onMutate: async (newComment) => {
await utils.posts.byId.cancel({ id: postId });
const previousData = utils.posts.byId.getData({ id: postId });
utils.posts.byId.setData({ id: postId }, (old) => {
if (!old) return old;
return {
...old,
comments: [
...old.comments,
{
id: "temp-id",
content: newComment.content,
author: { name: "You" },
createdAt: new Date(),
},
],
};
});
return { previousData };
},
onError: (_err, _vars, context) => {
if (context?.previousData) {
utils.posts.byId.setData({ id: postId }, context.previousData);
}
},
onSettled: () => {
utils.posts.byId.invalidate({ id: postId });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
createComment.mutate({
postId,
content: form.get("content") as string,
});
}}
>
<textarea name="content" required />
<button type="submit" disabled={createComment.isPending}>
Comment
</button>
</form>
);
}Optimistische Updates zeigen Änderungen sofort, während die Mutation im Hintergrund läuft. Schlägt die Mutation fehl, rollt der onError-Handler auf den vorherigen Zustand zurück. Der onSettled-Handler lädt die echten Daten neu — unabhängig von Erfolg oder Misserfolg.
Die wichtigsten Erkenntnisse
tRPC schließt die Lücke zwischen Backend- und Frontend-Typen zu Typsicherheit ganz ohne Generierungsschritt. Ändere einen Rückgabetyp in deiner Prozedur, und TypeScript markiert sofort jede konsumierende Komponente. Das ist keine inkrementelle Verbesserung — es ist ein fundamental anderes Entwicklungserlebnis.
Der Stack — tRPC + Zod + Prisma + TanStack Query — gibt dir Typsicherheit vom Datenbankschema über die API-Validierung bis zum Komponenten-Rendering. Jede Schicht inferiert Typen aus der vorherigen und bildet eine Kette, in der sich eine Schema-Änderung zur Compile-Zeit durch die gesamte Anwendung zieht.
Der Trade-off ist die Kopplung. tRPC funktioniert am besten, wenn Frontend und Backend im selben TypeScript-Projekt leben. Für öffentliche APIs, die von Dritten konsumiert werden, sind REST oder GraphQL mit generierten SDK-Typen weiterhin die richtige Wahl. Für Full-Stack-Anwendungen eines einzelnen Teams eliminiert tRPC eine ganze Kategorie von Bugs.


