tRPC: End-to-End Type Safety Zonder de Ceremonie
Hoe tRPC het API-contractprobleem elimineert, werkt met Next.js App Router, auth-middleware afhandelt, file uploads en subscriptions. Een real-world tRPC-setup vanaf nul.
Je kent het wel. Je verandert een veldnaam in je API-response. Je werkt het backend-type bij. Je deployt. En dan breekt de frontend in productie omdat iemand vergeten is de fetch-aanroep op regel 247 van Dashboard.tsx bij te werken. Het veld is nu undefined, het component rendert leeg, en je error tracking gaat om 2 uur 's nachts af.
Dit is het API-contractprobleem. Het is geen technologieprobleem. Het is een coördinatieprobleem. En geen hoeveelheid Swagger-documentatie of GraphQL-schema's zal het feit oplossen dat je frontend- en backend-types stilletjes uit sync kunnen raken.
tRPC lost dit op door ze simpelweg niet uit sync te laten raken. Er is geen schemabestand. Geen codegeneratiestap. Geen apart contract om te onderhouden. Je schrijft een TypeScript-functie op de server, en de client kent de exacte input- en outputtypes op compileertijd. Als je een veld hernoemt, compileert de frontend niet meer totdat je het repareert.
Dat is de belofte. Laat me je laten zien hoe het werkelijk werkt, waar het uitblinkt, en waar je het absoluut niet moet gebruiken.
Het Probleem, Preciezer#
Laten we kijken naar hoe de meeste teams tegenwoordig API's bouwen.
REST + OpenAPI: Je schrijft je endpoints. Misschien voeg je Swagger-annotaties toe. Misschien genereer je een client-SDK vanuit de OpenAPI-spec. Maar de spec is een apart artefact. Het kan verouderd raken. De generatiestap is weer iets in je CI-pipeline dat kapot kan gaan of vergeten kan worden. En de gegenereerde types zijn vaak lelijk — diep geneste paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"] monsters.
GraphQL: Betere type safety, maar enorme ceremonie. Je schrijft een schema in SDL. Je schrijft resolvers. Je genereert types vanuit het schema. Je schrijft queries op de client. Je genereert types vanuit de queries. Dat zijn minstens twee codegeneratiestappen, een schemabestand en een buildstap die iedereen moet onthouden te draaien. Voor een team dat al zowel frontend als backend beheert, is dit veel infrastructuur voor een probleem dat een eenvoudigere oplossing heeft.
Handmatige fetch-aanroepen: De meest voorkomende aanpak en de gevaarlijkste. Je schrijft fetch("/api/users"), cast het resultaat naar User[], en hoopt op het beste. Er is nul compile-time veiligheid. De type assertion is een leugen die je TypeScript vertelt.
// De leugen die elke frontend-ontwikkelaar heeft verteld
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 hopen dat dit klopttRPC kiest een totaal andere aanpak. In plaats van je API te beschrijven in een apart formaat en types te genereren, schrijf je gewone TypeScript-functies op de server en importeer je hun types direct in de client. Geen generatiestap. Geen schemabestand. Geen drift.
Kernconcepten#
Voordat we iets opzetten, laten we het mentale model begrijpen.
Router#
Een tRPC-router is een verzameling procedures die samen gegroepeerd zijn. Beschouw het als een controller in MVC, behalve dat het gewoon een plain object is met ingebouwde type inference.
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const appRouter = t.router({
user: t.router({
list: t.procedure.query(/* ... */),
byId: t.procedure.input(/* ... */).query(/* ... */),
create: t.procedure.input(/* ... */).mutation(/* ... */),
}),
post: t.router({
list: t.procedure.query(/* ... */),
publish: t.procedure.input(/* ... */).mutation(/* ... */),
}),
});
export type AppRouter = typeof appRouter;Die AppRouter type-export is de hele truc. De client importeert dit type — niet de runtimecode, alleen het type — en krijgt volledige autocompletion en typechecking voor elke procedure.
Procedure#
Een procedure is een enkel endpoint. Er zijn drie soorten:
- Query: Leesoperaties. Komt overeen met HTTP GET-semantiek. Gecacht door TanStack Query.
- Mutation: Schrijfoperaties. Komt overeen met HTTP POST. Niet gecacht.
- Subscription: Realtime streams. Gebruikt WebSockets.
Context#
Context is de request-scoped data die beschikbaar is voor elke procedure. Databaseverbindingen, de geauthenticeerde gebruiker, request headers — alles wat je in Express's req-object zou zetten komt hier.
Middleware#
Middleware transformeert de context of bewaakt toegang. Het meest voorkomende patroon is een auth-middleware die controleert op een geldige sessie en ctx.user toevoegt.
De Type Inference-keten#
Dit is het cruciale mentale model. Wanneer je een procedure als volgt definieert:
t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
// input is getypeerd als { id: string }
return db.user.findUnique({ where: { id: input.id } });
});Het returntype stroomt helemaal door naar de client. Als db.user.findUnique een User | null retourneert, zal de useQuery-hook van de client data getypeerd hebben als User | null. Geen handmatig typen. Geen casten. Het wordt end-to-end afgeleid.
Setup met Next.js App Router#
Laten we dit vanaf nul bouwen. Ik ga ervan uit dat je een Next.js 14+ project hebt met de App Router.
Dependencies Installeren#
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodStap 1: Initialiseer tRPC op de Server#
Maak je tRPC-instantie aan en definieer het context-type.
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import superjson from "superjson";
import { ZodError } from "zod";
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
const session = await getSession(opts.req);
return {
session,
db: prisma,
req: opts.req,
};
}
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;Een paar dingen om op te merken:
superjsontransformer: tRPC serialiseert data standaard als JSON, wat betekent datDate-objecten,Map,Seten andere niet-JSON-types verloren gaan. superjson behoudt ze.- Error formatter: We koppelen Zod-validatiefouten aan de response zodat de client veldniveau-fouten kan tonen.
createTRPCContext: Deze functie draait bij elk verzoek. Hier parse je de sessie, stel je de databaseverbinding in en bouw je het contextobject.
Stap 2: Definieer Je Router#
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
export const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: ctx.user.id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const updated = await ctx.db.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
bio: input.bio,
},
});
return updated;
}),
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
image: true,
bio: true,
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
});// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
import { notificationRouter } from "./notification";
export const appRouter = router({
user: userRouter,
post: postRouter,
notification: notificationRouter,
});
export type AppRouter = typeof appRouter;Stap 3: Expose via Next.js Route Handler#
In de App Router draait tRPC als een standaard Route Handler. Geen custom server, geen speciale Next.js-plugin.
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };Dat is het. Zowel GET als POST worden afgehandeld. Queries gaan via GET (met URL-gecodeerde input), mutations gaan via POST.
Stap 4: Stel de Client In#
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Let op: we importeren AppRouter alleen als een type. Er lekt geen servercode in de client-bundle.
Stap 5: Provider Setup#
// src/components/providers/TRPCProvider.tsx
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
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}`;
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
})
);
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}// src/app/layout.tsx (relevant gedeelte)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Stap 6: Gebruik in Componenten#
// src/app/dashboard/page.tsx
"use client";
import { trpc } from "@/lib/trpc";
export default function DashboardPage() {
const { data: user, isLoading, error } = trpc.user.me.useQuery();
if (isLoading) return <DashboardSkeleton />;
if (error) return <ErrorDisplay message={error.message} />;
return (
<div>
<h1>Welcome back, {user.name}</h1>
<p>Member since {user.createdAt.toLocaleDateString()}</p>
</div>
);
}Die user.name is volledig getypeerd. Als je het verkeerd schrijft als user.nme, vangt TypeScript het direct. Als je de server wijzigt om displayName in plaats van name te retourneren, toont elk client-gebruik een compilefout. Geen runtime-verrassingen.
Context en Middleware#
Context en middleware zijn waar tRPC van een "leuke typetrik" naar een "productie-klaar framework" gaat.
Context Aanmaken#
De contextfunctie draait bij elk verzoek. Hier is een real-world versie:
// src/server/trpc.ts
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
const session = await getServerSession(authOptions);
return {
session,
user: session?.user ?? null,
db: prisma,
headers: Object.fromEntries(opts.req.headers),
};
}
type Context = Awaited<ReturnType<typeof createTRPCContext>>;Auth Middleware#
Het meest voorkomende middleware-patroon is het scheiden van publieke en beschermde procedures:
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
return next({
ctx: {
// Overschrijf het context-type — user is niet meer nullable
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);Nadat deze middleware draait, is ctx.user in elke protectedProcedure gegarandeerd niet-null. Het typesysteem dwingt dit af. Je kunt niet per ongeluk ctx.user.id benaderen in een publieke procedure zonder dat TypeScript klaagt.
Rolgebaseerde Middleware#
Je kunt middleware samenstellen voor meer gedetailleerde toegangscontrole:
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (ctx.user.role !== "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}
return next({
ctx: {
user: ctx.user,
},
});
});
export const adminProcedure = t.procedure.use(isAdmin);Logging Middleware#
Middleware is niet alleen voor auth. Hier is een performance-logging middleware:
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`⚠️ Slow ${type} ${path}: ${duration}ms`);
}
return result;
});
// Pas toe op alle procedures
export const publicProcedure = t.procedure.use(loggerMiddleware);Rate Limiting Middleware#
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "10 s"),
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const ip = ctx.headers["x-forwarded-for"] ?? "unknown";
const { success, remaining } = await ratelimit.limit(ip);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Try again later.`,
});
}
return next();
});Inputvalidatie met Zod#
tRPC gebruikt Zod voor inputvalidatie. Dit is geen optionele versiering — het is het mechanisme dat ervoor zorgt dat inputs veilig zijn aan zowel client- als serverzijde.
Basisvalidatie#
const postRouter = router({
create: protectedProcedure
.input(
z.object({
title: z.string().min(1, "Title is required").max(200, "Title too long"),
content: z.string().min(10, "Content must be at least 10 characters"),
categoryId: z.string().uuid("Invalid category ID"),
tags: z.array(z.string()).max(5, "Maximum 5 tags").default([]),
published: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// input is volledig getypeerd:
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});De Dubbele Validatietrik#
Hier is iets subtiels: Zod-validatie draait aan beide kanten. Op de client valideert tRPC de input voordat het verzoek wordt verstuurd. Als de input ongeldig is, verlaat het verzoek nooit de browser. Op de server valideert hetzelfde schema opnieuw als beveiligingsmaatregel.
Dit betekent dat je gratis directe client-side validatie krijgt:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function CreatePostForm() {
const [title, setTitle] = useState("");
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
utils.post.list.invalidate();
},
onError: (error) => {
// Zod-fouten komen gestructureerd binnen
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
// fieldErrors.title?: string[]
// fieldErrors.content?: string[]
console.log(fieldErrors);
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
createPost.mutate({
title,
content: "...",
categoryId: "...",
});
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
{createPost.error?.data?.zodError?.fieldErrors.title && (
<span className="text-red-500">
{createPost.error.data.zodError.fieldErrors.title[0]}
</span>
)}
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? "Creating..." : "Create Post"}
</button>
</form>
);
}Complexe Inputpatronen#
// Discriminated unions
const searchInput = z.discriminatedUnion("type", [
z.object({
type: z.literal("user"),
query: z.string(),
includeInactive: z.boolean().default(false),
}),
z.object({
type: z.literal("post"),
query: z.string(),
category: z.string().optional(),
}),
]);
// Paginatie-input hergebruikt over procedures heen
const paginationInput = z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(100).default(20),
});
const postRouter = router({
infiniteList: publicProcedure
.input(
z.object({
...paginationInput.shape,
category: z.string().optional(),
sortBy: z.enum(["newest", "popular", "trending"]).default("newest"),
})
)
.query(async ({ ctx, input }) => {
const { cursor, limit, category, sortBy } = input;
const posts = await ctx.db.post.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
where: category ? { categoryId: category } : undefined,
orderBy:
sortBy === "newest"
? { createdAt: "desc" }
: sortBy === "popular"
? { likes: "desc" }
: { score: "desc" },
});
let nextCursor: string | undefined;
if (posts.length > limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return {
posts,
nextCursor,
};
}),
});Optionele Inputs#
Niet elke procedure heeft input nodig. Queries vaak niet:
const statsRouter = router({
// Geen input nodig
overview: publicProcedure.query(async ({ ctx }) => {
const [userCount, postCount, commentCount] = await Promise.all([
ctx.db.user.count(),
ctx.db.post.count(),
ctx.db.comment.count(),
]);
return { userCount, postCount, commentCount };
}),
// Optionele filters
detailed: publicProcedure
.input(
z
.object({
from: z.date().optional(),
to: z.date().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const where = {
...(input?.from && { createdAt: { gte: input.from } }),
...(input?.to && { createdAt: { lte: input.to } }),
};
return ctx.db.post.groupBy({
by: ["categoryId"],
where,
_count: true,
});
}),
});Foutafhandeling#
De foutafhandeling van tRPC is gestructureerd, type-safe en integreert netjes met zowel HTTP-semantiek als client-side UI.
Fouten Gooien op de Server#
import { TRPCError } from "@trpc/server";
const postRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
if (post.authorId !== ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only delete your own posts",
});
}
await ctx.db.post.delete({ where: { id: input.id } });
return { success: true };
}),
});tRPC-foutcodes worden gekoppeld aan HTTP-statuscodes:
| tRPC Code | HTTP Status | Wanneer Gebruiken |
|---|---|---|
BAD_REQUEST | 400 | Ongeldige input voorbij Zod-validatie |
UNAUTHORIZED | 401 | Niet ingelogd |
FORBIDDEN | 403 | Ingelogd maar onvoldoende rechten |
NOT_FOUND | 404 | Resource bestaat niet |
CONFLICT | 409 | Dubbele resource |
TOO_MANY_REQUESTS | 429 | Rate limit overschreden |
INTERNAL_SERVER_ERROR | 500 | Onverwachte serverfout |
Aangepaste Foutopmaak#
Weet je nog de error formatter uit onze setup? Zo werkt die in de praktijk:
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
// Voeg aangepaste velden toe
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
},
};
},
});Foutafhandeling aan de Clientzijde#
"use client";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
export function DeletePostButton({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const deletePost = trpc.post.delete.useMutation({
onSuccess: () => {
toast.success("Post deleted");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("You don't have permission to delete this post");
break;
case "NOT_FOUND":
toast.error("This post no longer exists");
utils.post.list.invalidate();
break;
default:
toast.error("Something went wrong. Please try again.");
}
},
});
return (
<button
onClick={() => deletePost.mutate({ id: postId })}
disabled={deletePost.isPending}
>
{deletePost.isPending ? "Deleting..." : "Delete"}
</button>
);
}Globale Foutafhandeling#
Je kunt een globale foutafhandeling instellen die alle onafgehandelde tRPC-fouten opvangt:
// In je TRPCProvider
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Globale fallback voor onafgehandelde mutation-fouten
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// Redirect naar login
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);Mutations en Optimistische Updates#
Mutations zijn waar tRPC echt goed integreert met TanStack Query. Laten we een real-world patroon bekijken: een like-knop met optimistische updates.
Basis Mutation#
// Server
const postRouter = router({
toggleLike: protectedProcedure
.input(z.object({ postId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.like.findUnique({
where: {
userId_postId: {
userId: ctx.user.id,
postId: input.postId,
},
},
});
if (existing) {
await ctx.db.like.delete({
where: { id: existing.id },
});
return { liked: false };
}
await ctx.db.like.create({
data: {
userId: ctx.user.id,
postId: input.postId,
},
});
return { liked: true };
}),
});Optimistische Updates#
De gebruiker klikt op "Like." Je wilt niet 200ms wachten op de serverresponse om de UI bij te werken. Optimistische updates lossen dit op: werk de UI direct bij en rol terug als de server het afwijst.
"use client";
import { trpc } from "@/lib/trpc";
export function LikeButton({ postId, initialLiked, initialCount }: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const utils = trpc.useUtils();
const toggleLike = trpc.post.toggleLike.useMutation({
onMutate: async ({ postId }) => {
// Annuleer uitgaande refetches zodat ze onze optimistische update niet overschrijven
await utils.post.byId.cancel({ id: postId });
// Maak een snapshot van de vorige waarde
const previousPost = utils.post.byId.getData({ id: postId });
// Werk de cache optimistisch bij
utils.post.byId.setData({ id: postId }, (old) => {
if (!old) return old;
return {
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
};
});
// Retourneer de snapshot voor rollback
return { previousPost };
},
onError: (_error, { postId }, context) => {
// Rol terug naar de vorige waarde bij een fout
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// Refetch altijd na fout of succes om de serverstatus te garanderen
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
♥ {initialCount}
</button>
);
}Het patroon is altijd hetzelfde:
onMutate: Annuleer queries, maak een snapshot van de huidige data, pas de optimistische update toe, retourneer de snapshot.onError: Rol terug met de snapshot.onSettled: Invalideer de query zodat deze opnieuw ophaalt van de server, ongeacht succes of fout.
Deze driestapdans zorgt ervoor dat de UI altijd responsief is en uiteindelijk consistent met de server.
Gerelateerde Queries Invalideren#
Na een mutation moet je vaak gerelateerde data vernieuwen. tRPC's useUtils() maakt dit ergonomisch:
const createComment = trpc.comment.create.useMutation({
onSuccess: (_data, variables) => {
// Invalideer de commentaarlijst van de post
utils.comment.listByPost.invalidate({ postId: variables.postId });
// Invalideer de post zelf (het aantal reacties is veranderd)
utils.post.byId.invalidate({ id: variables.postId });
// Invalideer ALLE postlijsten (reactieaantallen in lijstweergaven)
utils.post.list.invalidate();
},
});Batching en Subscriptions#
HTTP Batching#
Standaard combineert tRPC met httpBatchLink meerdere gelijktijdige verzoeken in een enkele HTTP-aanroep. Als een component rendert en drie queries afvuurt:
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}Deze drie queries worden automatisch gebatcht in een enkel HTTP-verzoek: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
De server verwerkt alle drie, retourneert alle drie de resultaten in een enkele response, en TanStack Query verdeelt de resultaten naar elke hook. Geen configuratie nodig.
Je kunt batching voor specifieke aanroepen uitschakelen indien nodig:
// In je provider, gebruik splitLink om specifieke procedures anders te routeren
import { splitLink, httpBatchLink, httpLink } from "@trpc/client";
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.path === "post.infiniteList",
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
maxURLLength: 2048,
}),
}),
],
})
);WebSocket Subscriptions#
Voor realtime-functies ondersteunt tRPC subscriptions via WebSockets. Dit vereist een aparte WebSocket-server (Next.js ondersteunt WebSockets niet native in Route Handlers).
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
// In-memory event emitter (gebruik Redis pub/sub in productie)
import { EventEmitter } from "events";
const eventEmitter = new EventEmitter();
export const notificationRouter = router({
onNew: protectedProcedure.subscription(({ ctx }) => {
return observable<Notification>((emit) => {
const handler = (notification: Notification) => {
if (notification.userId === ctx.user.id) {
emit.next(notification);
}
};
eventEmitter.on("notification", handler);
return () => {
eventEmitter.off("notification", handler);
};
});
}),
// Mutation die een notificatie triggert
markAsRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const notification = await ctx.db.notification.update({
where: { id: input.id, userId: ctx.user.id },
data: { readAt: new Date() },
});
return notification;
}),
});Op de client:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
trpc.notification.onNew.useSubscription(undefined, {
onData: (notification) => {
setNotifications((prev) => [notification, ...prev]);
toast.info(notification.message);
},
onError: (error) => {
console.error("Subscription error:", error);
},
});
return (
<div>
<span className="badge">{notifications.length}</span>
{/* notificatielijst UI */}
</div>
);
}Voor het WebSocket-transport heb je een apart serverproces nodig. Hier is een minimale setup met de ws-bibliotheek:
// ws-server.ts (apart proces)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
const wss = new WebSocketServer({ port: 3001 });
const handler = applyWSSHandler({
wss,
router: appRouter,
createContext: createTRPCContext,
});
wss.on("connection", (ws) => {
console.log(`Connection opened (${wss.clients.size} total)`);
ws.once("close", () => {
console.log(`Connection closed (${wss.clients.size} total)`);
});
});
process.on("SIGTERM", () => {
handler.broadcastReconnectNotification();
wss.close();
});
console.log("WebSocket server listening on ws://localhost:3001");En de client heeft een wsLink nodig voor subscriptions:
import { wsLink, createWSClient } from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
// Gebruik splitLink om subscriptions via WebSocket te routeren
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.type === "subscription",
true: wsLink({ client: wsClient, transformer: superjson }),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
}),
],
})
);Type-Safe Bestandsuploads#
tRPC ondersteunt bestandsuploads niet native. Het is een JSON-RPC-protocol — binaire data valt buiten zijn bereik. Maar je kunt een type-safe uploadflow bouwen door tRPC te combineren met presigned URL's.
Het patroon:
- Client vraagt tRPC om een presigned upload-URL.
- tRPC valideert het verzoek, controleert rechten, genereert de URL.
- Client uploadt direct naar S3 met de presigned URL.
- Client meldt aan tRPC dat de upload voltooid is.
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export const uploadRouter = router({
getPresignedUrl: protectedProcedure
.input(
z.object({
filename: z.string().min(1).max(255),
contentType: z.string().regex(/^(image|application)\//),
size: z.number().max(10 * 1024 * 1024, "File must be under 10MB"),
})
)
.mutation(async ({ ctx, input }) => {
const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: input.contentType,
ContentLength: input.size,
});
const presignedUrl = await getSignedUrl(s3, command, {
expiresIn: 300, // 5 minuten
});
// Sla de lopende upload op in de database
const upload = await ctx.db.upload.create({
data: {
key,
userId: ctx.user.id,
filename: input.filename,
contentType: input.contentType,
size: input.size,
status: "PENDING",
},
});
return {
uploadId: upload.id,
presignedUrl,
key,
};
}),
confirmUpload: protectedProcedure
.input(z.object({ uploadId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const upload = await ctx.db.upload.findUnique({
where: { id: input.uploadId, userId: ctx.user.id },
});
if (!upload) {
throw new TRPCError({ code: "NOT_FOUND" });
}
// Verifieer dat het bestand daadwerkelijk bestaat in S3
// (optioneel maar aanbevolen)
const confirmed = await ctx.db.upload.update({
where: { id: upload.id },
data: { status: "CONFIRMED" },
});
return {
url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${confirmed.key}`,
};
}),
});Op de client:
"use client";
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
export function FileUpload() {
const [uploading, setUploading] = useState(false);
const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
const confirmUpload = trpc.upload.confirmUpload.useMutation();
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
// Stap 1: Haal presigned URL op van tRPC
const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type,
size: file.size,
});
// Stap 2: Upload direct naar S3
const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Stap 3: Bevestig upload via tRPC
const { url } = await confirmUpload.mutateAsync({ uploadId });
toast.success(`File uploaded: ${url}`);
} catch (error) {
toast.error("Upload failed. Please try again.");
} finally {
setUploading(false);
}
},
[getPresignedUrl, confirmUpload]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>Uploading...</p>}
</div>
);
}De hele flow is type-safe. Het presigned URL-responsetype, het upload-ID-type, het bevestigingsresponsetype — allemaal afgeleid van de serverdefinities. Als je een nieuw veld toevoegt aan de presigned URL-response, weet de client het direct.
Server-Side Aanroepen en React Server Components#
Met Next.js App Router wil je vaak data ophalen in Server Components. tRPC ondersteunt dit via server-side callers:
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// Gebruik in een Server Component
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
const ctx = await createTRPCContext({
req: new Request("http://localhost"),
resHeaders: new Headers(),
});
const caller = createCaller(ctx);
const post = await caller.post.byId({ id: params.id });
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
{/* Client components voor interactieve onderdelen */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}Dit geeft je het beste van twee werelden: server-gerenderde initiële data met volledige type safety, en client-side interactiviteit voor mutations en realtime-functies.
tRPC Procedures Testen#
Testen is eenvoudig omdat procedures gewoon functies zijn. Je hoeft geen HTTP-server op te starten.
// src/server/routers/user.test.ts
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "./routers/_app";
import { createCaller } from "./trpc";
describe("user router", () => {
it("returns the current user profile", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
prismaMock.user.findUnique.mockResolvedValue({
id: "user-1",
name: "Test User",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
const result = await caller.user.me();
expect(result).toEqual({
id: "user-1",
name: "Test User",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
});
it("throws UNAUTHORIZED for unauthenticated requests", async () => {
const caller = createCaller({
user: null,
db: prismaMock,
session: null,
headers: {},
});
await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
});
it("validates input with Zod", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
await expect(
caller.user.updateProfile({
name: "", // min lengte 1
})
).rejects.toThrow();
});
});Geen mocking van HTTP-lagen, geen supertest, geen route matching. Roep gewoon de functie aan en assert het resultaat. Dit is een van tRPC's onderschatte voordelen: testen is triviaal eenvoudig omdat de transportlaag een implementatiedetail is.
Wanneer tRPC NIET Gebruiken#
tRPC is geen universele oplossing. Hier is waar het tekortschiet:
Publieke API's#
Als je een API bouwt die externe ontwikkelaars gaan gebruiken, is tRPC de verkeerde keuze. Externe gebruikers hebben geen toegang tot je TypeScript-types. Ze hebben een gedocumenteerd, stabiel contract nodig — OpenAPI/Swagger voor REST, of een GraphQL-schema. De type safety van tRPC werkt alleen wanneer zowel de client als de server dezelfde TypeScript-codebase delen.
Mobiele Apps (Tenzij Je TypeScript Gebruikt)#
Als je mobiele app geschreven is in Swift, Kotlin of Dart, biedt tRPC niets. De types overschrijden geen taalgrenzen. Je zou theoretisch een OpenAPI-spec kunnen genereren vanuit tRPC-routes met trpc-openapi, maar op dat moment voeg je ceremonie weer toe. Gebruik dan gewoon REST vanaf het begin.
Microservices#
tRPC gaat uit van een enkele TypeScript-codebase. Als je backend verdeeld is over meerdere services in verschillende talen, kan tRPC niet helpen met inter-servicecommunicatie. Gebruik daarvoor gRPC, REST of message queues.
Grote Teams met Aparte Frontend/Backend Repo's#
Als je frontend en backend in aparte repositories wonen met aparte deploy-pipelines, verlies je het kernvoordeel van tRPC. Het delen van types vereist een monorepo of een gedeeld pakket. Je kunt het AppRouter-type publiceren als een npm-pakket, maar dan heb je een versioneringsprobleem dat REST + OpenAPI natuurlijker afhandelt.
Wanneer Je REST-semantiek Nodig Hebt#
Als je HTTP-cachingheaders, content negotiation, ETags of andere REST-specifieke functies nodig hebt, zal tRPC's abstractie over HTTP je tegenwerken. tRPC behandelt HTTP als een transportdetail, niet als een functie.
Het Beslissingskader#
Zo besluit ik:
| Scenario | Aanbeveling |
|---|---|
| Zelfde-repo fullstack TypeScript-app | tRPC — maximaal voordeel, minimale overhead |
| Interne tool / admin dashboard | tRPC — ontwikkelsnelheid is de prioriteit |
| Publieke API voor externe devs | REST + OpenAPI — gebruikers hebben docs nodig, geen types |
| Mobiel + web (niet-TS mobiel) | REST of GraphQL — taalonafhankelijke contracten nodig |
| Realtime-zwaar (chat, gaming) | tRPC subscriptions of raw WebSockets afhankelijk van complexiteit |
| Aparte frontend/backend teams | GraphQL — schema is het contract tussen teams |
Praktische Tips uit Productie#
Een paar dingen die ik heb geleerd door tRPC in productie te draaien die niet in de docs staan:
Houd routers klein. Een enkel routerbestand mag niet langer zijn dan 200 regels. Splits per domein: userRouter, postRouter, billingRouter. Elk in zijn eigen bestand.
Gebruik createCallerFactory voor server-side aanroepen. Grijp niet naar fetch wanneer je je eigen API aanroept vanuit een Server Component. De caller factory geeft je dezelfde type safety met nul HTTP-overhead.
Overoptimaliseer batching niet. De standaard httpBatchLink is bijna altijd voldoende. Ik heb teams dagenlang zien besteden aan het opzetten van splitLink-configuraties voor marginale winsten. Profiel eerst.
Stel staleTime in op QueryClient. De standaard staleTime van 0 betekent dat elk focus-event een refetch triggert. Stel het in op iets redelijks (30 seconden tot 5 minuten) op basis van je dataversheidsvereisten.
Gebruik superjson vanaf dag één. Later toevoegen betekent dat je elke client en server tegelijkertijd moet migreren. Het is een configuratie van één regel die je redt van Date-serialisatiebugs.
Error boundaries zijn je vriend. Wikkel tRPC-zware paginasecties in React error boundaries. Een enkele mislukte query mag niet de hele pagina laten crashen.
"use client";
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert" className="p-4 bg-red-50 rounded-lg">
<p className="font-medium text-red-800">Something went wrong</p>
<pre className="text-sm text-red-600 mt-2">{error.message}</pre>
<button
onClick={resetErrorBoundary}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded"
>
Try again
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}Afsluiting#
tRPC is geen vervanging voor REST of GraphQL. Het is een ander hulpmiddel voor een specifieke situatie: wanneer je zowel de client als de server beheert, beide TypeScript zijn, en je de kortst mogelijke weg wilt van "ik heb de backend gewijzigd" naar "de frontend weet ervan."
In die situatie komt niets anders in de buurt. Geen codegeneratie, geen schemabestanden, geen drift. Gewoon TypeScript dat doet waar TypeScript het beste in is: fouten opvangen voordat ze productie bereiken.
De afweging is duidelijk: je geeft protocolniveau-interoperabiliteit op (geen niet-TypeScript clients) in ruil voor ontwikkelsnelheid en compile-time veiligheid die op een andere manier moeilijk te bereiken is.
Voor de meeste fullstack TypeScript-applicaties is dat een afweging die het waard is.