tRPC : La sécurité de types de bout en bout sans la cérémonie
Comment tRPC élimine le problème du contrat API, fonctionne avec le App Router Next.js, gère le middleware d'authentification, les uploads de fichiers et les subscriptions. Un vrai setup tRPC à partir de zéro.
Vous connaissez le scénario. Vous changez un nom de champ dans votre réponse API. Vous mettez à jour le type côté backend. Vous déployez. Puis le frontend casse en production parce que quelqu'un a oublié de mettre à jour l'appel fetch à la ligne 247 de Dashboard.tsx. Le champ est maintenant undefined, le composant affiche du vide, et votre suivi d'erreurs s'allume à 2h du matin.
C'est le problème du contrat API. Ce n'est pas un problème de technologie. C'est un problème de coordination. Et aucune quantité de docs Swagger ou de schémas GraphQL ne résoudra le fait que les types de votre frontend et de votre backend peuvent diverger silencieusement.
tRPC résout cela en refusant de les laisser diverger. Il n'y a pas de fichier de schéma. Pas d'étape de génération de code. Pas de contrat séparé à maintenir. Vous écrivez une fonction TypeScript sur le serveur, et le client connaît ses types d'entrée et de sortie exacts à la compilation. Si vous renommez un champ, le frontend ne compilera pas tant que vous ne l'aurez pas corrigé.
C'est la promesse. Laissez-moi vous montrer comment ça fonctionne réellement, où ça brille, et où vous ne devriez absolument pas l'utiliser.
Le problème, plus précisément#
Regardons comment la plupart des équipes construisent des API aujourd'hui.
REST + OpenAPI : Vous écrivez vos endpoints. Peut-être ajoutez-vous des annotations Swagger. Peut-être générez-vous un SDK client à partir de la spec OpenAPI. Mais la spec est un artefact séparé. Elle peut devenir obsolète. L'étape de génération est une chose de plus dans votre pipeline CI qui peut casser ou être oubliée. Et les types générés sont souvent laids — des monstres profondément imbriqués paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"].
GraphQL : Meilleure sécurité de types, mais une cérémonie énorme. Vous écrivez un schéma en SDL. Vous écrivez des resolvers. Vous générez des types à partir du schéma. Vous écrivez des queries côté client. Vous générez des types à partir des queries. C'est au moins deux étapes de génération de code, un fichier de schéma et une étape de build que tout le monde doit se rappeler d'exécuter. Pour une équipe qui contrôle déjà le frontend et le backend, c'est beaucoup d'infrastructure pour un problème qui a une solution plus simple.
Appels fetch manuels : L'approche la plus courante et la plus dangereuse. Vous écrivez fetch("/api/users"), castez le résultat en User[], et espérez le meilleur. Il n'y a zéro sécurité à la compilation. L'assertion de type est un mensonge que vous racontez à TypeScript.
// Le mensonge que chaque développeur frontend a raconté
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 espérons que c'est correcttRPC adopte une approche entièrement différente. Au lieu de décrire votre API dans un format séparé et de générer des types, vous écrivez des fonctions TypeScript simples sur le serveur et importez leurs types directement dans le client. Pas d'étape de génération. Pas de fichier de schéma. Pas de dérive.
Concepts fondamentaux#
Avant de configurer quoi que ce soit, comprenons le modèle mental.
Router#
Un router tRPC est une collection de procédures regroupées ensemble. Pensez-y comme un contrôleur en MVC, sauf que c'est juste un objet simple avec de l'inférence de types intégrée.
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;Cet export de type AppRouter est tout le tour de magie. Le client importe ce type — pas le code runtime, juste le type — et obtient l'auto-complétion complète et la vérification de types pour chaque procédure.
Procédure#
Une procédure est un seul endpoint. Il en existe trois types :
- Query : Opérations de lecture. Correspond à la sémantique HTTP GET. Mise en cache par TanStack Query.
- Mutation : Opérations d'écriture. Correspond à HTTP POST. Non mise en cache.
- Subscription : Flux temps réel. Utilise les WebSockets.
Contexte#
Le contexte est la donnée liée à la requête disponible pour chaque procédure. Connexions à la base de données, utilisateur authentifié, en-têtes de requête — tout ce que vous mettriez dans l'objet req d'Express va ici.
Middleware#
Le middleware transforme le contexte ou contrôle l'accès. Le pattern le plus courant est un middleware d'authentification qui vérifie une session valide et ajoute ctx.user.
La chaîne d'inférence de types#
C'est le modèle mental critique. Quand vous définissez une procédure comme ceci :
t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
// input est typé comme { id: string }
return db.user.findUnique({ where: { id: input.id } });
});Le type de retour se propage jusqu'au client. Si db.user.findUnique retourne User | null, le hook useQuery du client aura data typé comme User | null. Pas de typage manuel. Pas de cast. C'est inféré de bout en bout.
Configuration avec le App Router Next.js#
Construisons cela à partir de zéro. Je suppose que vous avez un projet Next.js 14+ avec le App Router.
Installation des dépendances#
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodÉtape 1 : Initialiser tRPC côté serveur#
Créez votre instance tRPC et définissez le type de contexte.
// 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;Quelques points à noter :
- Transformateur
superjson: tRPC sérialise les données en JSON par défaut, ce qui signifie que les objetsDate,Map,Setet autres types non-JSON sont perdus. superjson les préserve. - Formateur d'erreurs : Nous attachons les erreurs de validation Zod à la réponse pour que le client puisse afficher des erreurs par champ.
createTRPCContext: Cette fonction s'exécute à chaque requête. C'est là que vous parsez la session, configurez la connexion à la base de données et construisez l'objet contexte.
Étape 2 : Définir votre 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;Étape 3 : Exposer via un Route Handler Next.js#
Avec le App Router, tRPC s'exécute comme un Route Handler standard. Pas de serveur personnalisé, pas de plugin Next.js spécial.
// 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 };C'est tout. GET et POST sont gérés. Les queries passent par GET (avec l'entrée encodée dans l'URL), les mutations passent par POST.
Étape 4 : Configurer le client#
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Note : nous importons AppRouter comme type uniquement. Aucun code serveur ne fuit dans le bundle client.
Étape 5 : Configuration du Provider#
// 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 (partie pertinente)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Étape 6 : Utilisation dans les composants#
// 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>Bienvenue, {user.name}</h1>
<p>Membre depuis le {user.createdAt.toLocaleDateString()}</p>
</div>
);
}Ce user.name est entièrement typé. Si vous l'écrivez mal en user.nme, TypeScript le détecte immédiatement. Si vous changez le serveur pour retourner displayName au lieu de name, chaque utilisation côté client affichera une erreur de compilation. Pas de surprises au runtime.
Contexte et middleware#
Le contexte et le middleware sont ce qui fait passer tRPC de « joli tour de types » à « framework prêt pour la production ».
Création du contexte#
La fonction de contexte s'exécute à chaque requête. Voici une version réelle :
// 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>>;Middleware d'authentification#
Le pattern de middleware le plus courant est la séparation entre procédures publiques et protégées :
// 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: {
// Surcharger le type du contexte — user n'est plus nullable
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);Après l'exécution de ce middleware, ctx.user dans toute protectedProcedure est garanti non-null. Le système de types l'applique. Vous ne pouvez pas accidentellement accéder à ctx.user.id dans une procédure publique sans que TypeScript ne se plaigne.
Middleware basé sur les rôles#
Vous pouvez composer des middleware pour un contrôle d'accès plus granulaire :
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);Middleware de logging#
Le middleware n'est pas uniquement pour l'authentification. Voici un middleware de logging de performance :
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(`⚠️ ${type} lent ${path}: ${duration}ms`);
}
return result;
});
// Appliquer à toutes les procédures
export const publicProcedure = t.procedure.use(loggerMiddleware);Middleware de rate limiting#
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();
});Validation d'entrée avec Zod#
tRPC utilise Zod pour la validation d'entrée. Ce n'est pas une décoration optionnelle — c'est le mécanisme qui garantit que les entrées sont sûres côté client et serveur.
Validation basique#
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 est entièrement typé :
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});L'astuce de la double validation#
Voici quelque chose de subtil : la validation Zod s'exécute des deux côtés. Côté client, tRPC valide l'entrée avant d'envoyer la requête. Si l'entrée est invalide, la requête ne quitte jamais le navigateur. Côté serveur, le même schéma valide à nouveau par mesure de sécurité.
Cela signifie que vous obtenez gratuitement une validation côté client instantanée :
"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) => {
// Les erreurs Zod arrivent structurées
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 ? "Création..." : "Créer l'article"}
</button>
</form>
);
}Patterns d'entrée complexes#
// Unions discriminées
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(),
}),
]);
// Entrée de pagination réutilisée entre procédures
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,
};
}),
});Entrées optionnelles#
Toutes les procédures n'ont pas besoin d'entrée. Les queries souvent n'en ont pas :
const statsRouter = router({
// Pas d'entrée nécessaire
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 };
}),
// Filtres optionnels
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,
});
}),
});Gestion des erreurs#
La gestion des erreurs de tRPC est structurée, type-safe, et s'intègre proprement avec la sémantique HTTP et l'interface utilisateur côté client.
Lancer des erreurs côté serveur#
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 };
}),
});Les codes d'erreur tRPC correspondent aux codes de statut HTTP :
| Code tRPC | Statut HTTP | Quand l'utiliser |
|---|---|---|
BAD_REQUEST | 400 | Entrée invalide au-delà de la validation Zod |
UNAUTHORIZED | 401 | Non connecté |
FORBIDDEN | 403 | Connecté mais permissions insuffisantes |
NOT_FOUND | 404 | La ressource n'existe pas |
CONFLICT | 409 | Ressource en double |
TOO_MANY_REQUESTS | 429 | Limite de débit dépassée |
INTERNAL_SERVER_ERROR | 500 | Erreur serveur inattendue |
Formatage d'erreurs personnalisé#
Souvenez-vous du formateur d'erreurs de notre configuration ? Voici comment il fonctionne en pratique :
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,
// Ajouter des champs personnalisés
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
},
};
},
});Gestion des erreurs côté client#
"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("Article supprimé");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("Vous n'avez pas la permission de supprimer cet article");
break;
case "NOT_FOUND":
toast.error("Cet article n'existe plus");
utils.post.list.invalidate();
break;
default:
toast.error("Une erreur est survenue. Veuillez réessayer.");
}
},
});
return (
<button
onClick={() => deletePost.mutate({ id: postId })}
disabled={deletePost.isPending}
>
{deletePost.isPending ? "Suppression..." : "Supprimer"}
</button>
);
}Gestion globale des erreurs#
Vous pouvez configurer un gestionnaire d'erreurs global qui attrape toutes les erreurs tRPC non gérées :
// Dans votre TRPCProvider
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Fallback global pour les erreurs de mutation non gérées
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// Rediriger vers la connexion
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);Mutations et mises à jour optimistes#
Les mutations sont là où tRPC s'intègre vraiment bien avec TanStack Query. Regardons un pattern réel : un bouton de like avec des mises à jour optimistes.
Mutation basique#
// Serveur
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 };
}),
});Mises à jour optimistes#
L'utilisateur clique sur « Like ». Vous ne voulez pas attendre 200ms pour la réponse serveur avant de mettre à jour l'interface. Les mises à jour optimistes résolvent cela : mettez à jour l'interface immédiatement, puis annulez si le serveur rejette.
"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 }) => {
// Annuler les refetches en cours pour qu'ils n'écrasent pas notre mise à jour optimiste
await utils.post.byId.cancel({ id: postId });
// Capturer la valeur précédente
const previousPost = utils.post.byId.getData({ id: postId });
// Mettre à jour le cache de manière optimiste
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,
};
});
// Retourner la capture pour le rollback
return { previousPost };
},
onError: (_error, { postId }, context) => {
// Revenir à la valeur précédente en cas d'erreur
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// Toujours refetch après erreur ou succès pour assurer l'état serveur
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
♥ {initialCount}
</button>
);
}Le pattern est toujours le même :
onMutate: Annuler les queries, capturer les données actuelles, appliquer la mise à jour optimiste, retourner la capture.onError: Revenir en arrière en utilisant la capture.onSettled: Invalider la query pour qu'elle refetch depuis le serveur, quel que soit le résultat.
Cette danse en trois étapes garantit que l'interface est toujours réactive et finalement cohérente avec le serveur.
Invalidation des queries associées#
Après une mutation, vous avez souvent besoin de rafraîchir des données associées. Le useUtils() de tRPC rend cela ergonomique :
const createComment = trpc.comment.create.useMutation({
onSuccess: (_data, variables) => {
// Invalider la liste des commentaires de l'article
utils.comment.listByPost.invalidate({ postId: variables.postId });
// Invalider l'article lui-même (le nombre de commentaires a changé)
utils.post.byId.invalidate({ id: variables.postId });
// Invalider TOUTES les listes d'articles (compteurs de commentaires dans les vues liste)
utils.post.list.invalidate();
},
});Batching et subscriptions#
Batching HTTP#
Par défaut, tRPC avec httpBatchLink combine plusieurs requêtes simultanées en un seul appel HTTP. Si un composant se rend et lance trois queries :
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}Ces trois queries sont automatiquement regroupées en une seule requête HTTP : GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
Le serveur traite les trois, retourne les trois résultats dans une seule réponse, et TanStack Query distribue les résultats à chaque hook. Aucune configuration nécessaire.
Vous pouvez désactiver le batching pour des appels spécifiques si nécessaire :
// Dans votre provider, utilisez splitLink pour router des procédures spécifiques différemment
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,
}),
}),
],
})
);Subscriptions WebSocket#
Pour les fonctionnalités temps réel, tRPC supporte les subscriptions via WebSockets. Cela nécessite un serveur WebSocket séparé (Next.js ne supporte pas nativement les WebSockets dans les Route Handlers).
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
// Event emitter en mémoire (utilisez Redis pub/sub en production)
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 qui déclenche une notification
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;
}),
});Côté 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>
{/* interface de la liste de notifications */}
</div>
);
}Pour le transport WebSocket, vous avez besoin d'un processus serveur dédié. Voici un setup minimal avec la bibliothèque ws :
// ws-server.ts (processus séparé)
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");Et le client a besoin d'un wsLink pour les subscriptions :
import { wsLink, createWSClient } from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
// Utiliser splitLink pour router les subscriptions via WebSocket
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,
}),
}),
],
})
);Upload de fichiers type-safe#
tRPC ne gère pas les uploads de fichiers nativement. C'est un protocole JSON-RPC — les données binaires ne sont pas son domaine. Mais vous pouvez construire un flux d'upload type-safe en combinant tRPC avec des URLs pré-signées.
Le pattern :
- Le client demande à tRPC une URL d'upload pré-signée.
- tRPC valide la requête, vérifie les permissions, génère l'URL.
- Le client upload directement vers S3 en utilisant l'URL pré-signée.
- Le client notifie tRPC que l'upload est terminé.
// 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 minutes
});
// Stocker l'upload en attente dans la base de données
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" });
}
// Vérifier que le fichier existe bien dans S3
// (optionnel mais recommandé)
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}`,
};
}),
});Côté 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 {
// Étape 1 : Obtenir l'URL pré-signée via tRPC
const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type,
size: file.size,
});
// Étape 2 : Upload directement vers S3
const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Étape 3 : Confirmer l'upload via tRPC
const { url } = await confirmUpload.mutateAsync({ uploadId });
toast.success(`Fichier uploadé : ${url}`);
} catch (error) {
toast.error("L'upload a échoué. Veuillez réessayer.");
} finally {
setUploading(false);
}
},
[getPresignedUrl, confirmUpload]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>Upload en cours...</p>}
</div>
);
}L'ensemble du flux est type-safe. Le type de réponse de l'URL pré-signée, le type de l'ID d'upload, la réponse de confirmation — tous inférés à partir des définitions serveur. Si vous ajoutez un nouveau champ à la réponse de l'URL pré-signée, le client le sait immédiatement.
Appels côté serveur et React Server Components#
Avec le App Router Next.js, vous voulez souvent récupérer des données dans les Server Components. tRPC supporte cela via des callers côté serveur :
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// Utilisation dans un 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>
{/* Composants client pour les parties interactives */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}Cela vous donne le meilleur des deux mondes : des données initiales rendues côté serveur avec une sécurité de types complète, et de l'interactivité côté client pour les mutations et les fonctionnalités temps réel.
Tester les procédures tRPC#
Tester est simple parce que les procédures sont juste des fonctions. Vous n'avez pas besoin de démarrer un serveur HTTP.
// 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 length 1
})
).rejects.toThrow();
});
});Pas de mocking de couches HTTP, pas de supertest, pas de correspondance de routes. Vous appelez simplement la fonction et vérifiez le résultat. C'est un des avantages sous-estimés de tRPC : les tests sont trivialement simples parce que la couche de transport est un détail d'implémentation.
Quand NE PAS utiliser tRPC#
tRPC n'est pas une solution universelle. Voici où ça ne fonctionne pas :
API publiques#
Si vous construisez une API que des développeurs externes vont consommer, tRPC est le mauvais choix. Les consommateurs externes n'ont pas accès à vos types TypeScript. Ils ont besoin d'un contrat documenté et stable — OpenAPI/Swagger pour REST, ou un schéma GraphQL. La sécurité de types de tRPC ne fonctionne que lorsque le client et le serveur partagent la même base de code TypeScript.
Applications mobiles (sauf si vous utilisez TypeScript)#
Si votre application mobile est écrite en Swift, Kotlin ou Dart, tRPC n'offre rien. Les types ne traversent pas les frontières de langage. Vous pourriez théoriquement générer une spec OpenAPI à partir des routes tRPC en utilisant trpc-openapi, mais à ce stade vous rajoutez de la cérémonie. Utilisez simplement REST dès le départ.
Microservices#
tRPC suppose une seule base de code TypeScript. Si votre backend est divisé entre plusieurs services dans différents langages, tRPC ne peut pas aider pour la communication inter-services. Utilisez gRPC, REST ou des files de messages pour cela.
Grandes équipes avec des dépôts frontend/backend séparés#
Si votre frontend et votre backend vivent dans des dépôts séparés avec des pipelines de déploiement séparés, vous perdez l'avantage central de tRPC. Le partage de types nécessite un monorepo ou un package partagé. Vous pouvez publier le type AppRouter comme un package npm, mais vous avez maintenant un problème de versioning que REST + OpenAPI gère plus naturellement.
Quand vous avez besoin de la sémantique REST#
Si vous avez besoin d'en-têtes de cache HTTP, de négociation de contenu, d'ETags ou d'autres fonctionnalités spécifiques à REST, l'abstraction de tRPC sur HTTP va vous gêner. tRPC traite HTTP comme un détail de transport, pas comme une fonctionnalité.
Le cadre de décision#
Voici comment je décide :
| Scénario | Recommandation |
|---|---|
| Application fullstack TypeScript même dépôt | tRPC — bénéfice maximal, surcoût minimal |
| Outil interne / tableau de bord admin | tRPC — la vitesse de développement est la priorité |
| API publique pour développeurs tiers | REST + OpenAPI — les consommateurs ont besoin de docs, pas de types |
| Clients mobile + web (mobile non-TS) | REST ou GraphQL — besoin de contrats agnostiques du langage |
| Beaucoup de temps réel (chat, gaming) | Subscriptions tRPC ou WebSockets bruts selon la complexité |
| Équipes frontend/backend séparées | GraphQL — le schéma est le contrat entre les équipes |
Conseils pratiques de la production#
Quelques choses que j'ai apprises en faisant tourner tRPC en production et qui ne sont pas dans la documentation :
Gardez les routers petits. Un seul fichier de router ne devrait pas dépasser 200 lignes. Divisez par domaine : userRouter, postRouter, billingRouter. Chacun dans son propre fichier.
Utilisez createCallerFactory pour les appels côté serveur. N'utilisez pas fetch quand vous appelez votre propre API depuis un Server Component. La factory de caller vous donne la même sécurité de types sans surcoût HTTP.
N'optimisez pas trop le batching. Le httpBatchLink par défaut est presque toujours suffisant. J'ai vu des équipes passer des jours à configurer des splitLink pour des gains marginaux. Profilez d'abord.
Définissez staleTime dans le QueryClient. Le staleTime par défaut de 0 signifie que chaque événement de focus déclenche un refetch. Réglez-le sur quelque chose de raisonnable (30 secondes à 5 minutes) selon vos exigences de fraîcheur des données.
Utilisez superjson dès le premier jour. L'ajouter plus tard signifie migrer chaque client et serveur simultanément. C'est une configuration d'une ligne qui vous sauve des bugs de sérialisation de Date.
Les error boundaries sont vos amis. Enveloppez les sections de page riches en tRPC dans des error boundaries React. Une seule query échouée ne devrait pas faire tomber toute la page.
"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">Quelque chose s'est mal passé</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"
>
Réessayer
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Tableau de bord</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}Conclusion#
tRPC n'est pas un remplacement de REST ou GraphQL. C'est un outil différent pour une situation spécifique : quand vous contrôlez à la fois le client et le serveur, que les deux sont en TypeScript, et que vous voulez le chemin le plus court possible de « j'ai changé le backend » à « le frontend est au courant ».
Dans cette situation, rien d'autre ne s'en approche. Pas de génération de code, pas de fichiers de schéma, pas de dérive. Juste TypeScript faisant ce que TypeScript fait le mieux : attraper les erreurs avant qu'elles n'atteignent la production.
Le compromis est clair : vous renoncez à l'interopérabilité au niveau du protocole (pas de clients non-TypeScript) en échange de la vitesse de développement et de la sécurité à la compilation qu'il est difficile d'atteindre autrement.
Pour la plupart des applications fullstack TypeScript, c'est un compromis qui en vaut la peine.