La carte du système
Le vrai point d’ancrage de tRPC dans le projet : l’appel typé et l’import type qui le rend possible, le contexte reconstruit à chaque requête dans core.ts, superjson, et la route /api/trpc par laquelle tout entre.
Précédemment. La fracture n’est pas réseau, elle est structurelle : traduire à la main une donnée typée en JSON, puis la reconstruire, n’a plus de contrepartie quand le front et le back partagent leurs types. tRPC en est la conséquence.
Nous avons posé le problème, identifié la fracture et fait émerger une contrainte simple :
👉 la donnée ne doit plus être traduite en JSON pour circuler
Maintenant, nous entrons dans le système réel — non pas pour accumuler du code,
🧠 mais pour comprendre où et comment la continuité est garantie.
Une nouvelle carte (claire)#
Avant :
Scroller horizontalement si nécessaire
Maintenant :
Scroller horizontalement si nécessaire
💥 Le point clé :
👉 l’API disparaît comme frontière forte
👉 elle est remplacée par un flux structuré
Attention : l’API ne disparaît pas physiquement. Il existe toujours une route HTTP qui reçoit la requête — tRPC passe par le réseau, comme REST. Ce qui disparaît, c’est la frontière mentale et architecturale. Nous ne pensons plus en endpoints. Nous ne maintenons plus de contrat externe. Le réseau devient un détail d’implémentation, non plus un élément de conception.
Étape 1 — L’appel (sans sérialisation JSON)#
Côté client, nous ne “fabriquons plus une requête”.
Nous appelons une capacité du système :
Appel (pseudo-code)
await trpc.user.create({ email, password,})💡 Immédiatement :
- pas de
fetch - pas de
JSON.stringify - pas de route à construire
👉 nous restons dans le typé
Et ce “rester dans le typé” n’est pas seulement confortable pour le développeur. C’est ce qui permet à l’autocomplétion de fonctionner, au compilateur de détecter les erreurs avant l’exécution, et aux refactorings d’être sûrs. Le jour où nous renommons un champ dans le schéma Prisma, TypeScript nous indique immédiatement tous les endroits — côté serveur et côté client — où ce champ est utilisé.
Nous ne découvrons plus les erreurs seulement en production. Nous les découvrons durant le développement, pendant que nous tapons.
Comment le navigateur peut-il connaître les types d’une fonction qui s’exécute sur le serveur ? Le lien entre les deux côtés tient en une ligne :
import type { AppRouter } from "@backend/modules"C’est un import type : il est effacé à la compilation. Le navigateur reçoit les types du routeur, jamais son code, ni celui de Prisma, ni un secret du serveur. TypeScript lit ces types au moment où nous écrivons, et ils disparaissent du code livré. Il n’y a donc ni client à générer, ni fichier de types à tenir à jour : renommer un champ dans une procédure fait échouer pnpm check partout où le front l’utilise.
Étape 2 — Le contexte (introduit tôt, ancré dans le code)#
Avant toute logique métier, le système ne traite pas directement la donnée.
🧠 Il commence par reconstruire le cadre dans lequel cette donnée va prendre du sens.
Pour bien saisir ce concept de contexte, une image aide. Imaginons une salle de réunion où se tient chaque jour une nouvelle délibération. Avant chaque réunion, un concierge silencieux prépare la salle : il place les dossiers sur la table, vérifie qui a le badge d’accès, s’assure que la cafetière fonctionne, note l’heure et la liste des participants.
Quand les participants entrent, ils n’ont rien à préparer. Tout est déjà là. Ils peuvent se concentrer entièrement sur la délibération.
Le contexte tRPC, c’est ce concierge. Il travaille silencieusement, avant chaque procédure, pour que la procédure n’ait plus à se soucier de qui est connecté, de quelle est la session, ou d’où se trouve la base de données.
Dans une API classique, chaque requête arrive seule.
La route qui la reçoit doit reconstruire le contexte à partir de rien :
- lire le body
- retrouver la session
- vérifier l’utilisateur
- accéder à la base de données
👉 chaque route répète ce travail.
Et c’est exactement ce que nous voulons éviter. Pas seulement pour des raisons de performance ou de duplication de code, mais pour une raison plus profonde : chaque fois qu’un développeur doit se souvenir de faire ces vérifications, il peut les oublier. Et dans un backend, oublier une vérification, c’est ouvrir une faille.
Ici, ce travail est centralisé une fois pour toutes… dans core.ts.
import { initTRPC, TRPCError } from "@trpc/server";import superjson from "superjson";import type { NextRequest } from "next/server";import { prisma } from "@backend/prisma";import { getSessionFromRequest } from "@backend/modules/auth/getSessionFromRequest";import { APP_ROOT } from "../../../config/app";export type TRPCContext = { sessionClient: Awaited<ReturnType<typeof getSessionFromRequest>>; prisma: typeof prisma; requestIp: string | null; userAgent: string | null; appRoot: string;};type SessionClient = NonNullable<TRPCContext["sessionClient"]>;type AuthenticatedSessionClient = SessionClient & { user: NonNullable<SessionClient["user"]>;};type AuthenticatedTRPCContext = Omit<TRPCContext, "sessionClient"> & { sessionClient: AuthenticatedSessionClient;};function getIpFromRequest(req: Request | NextRequest): string | null { const xff = req.headers.get("x-forwarded-for"); if (xff) { const first = xff.split(",")[0]?.trim(); if (first) return first; } const realIp = req.headers.get("x-real-ip"); if (realIp) return realIp; return null;}export async function createTRPCContext({ req,}: { req: Request | NextRequest;}): Promise<TRPCContext> { const sessionClient = await getSessionFromRequest(req); return { sessionClient, prisma, requestIp: getIpFromRequest(req), userAgent: req.headers.get("user-agent"), appRoot: APP_ROOT, };}Lecture guidée du fichier core.ts#
Comment le contexte est défini, typé et injecté
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { initTRPC, TRPCError } from "@trpc/server";import superjson from "superjson";import type { NextRequest } from "next/server";import { prisma } from "@backend/prisma";import { getSessionFromRequest } from "@backend/modules/auth/getSessionFromRequest";import { APP_ROOT } from "../../../config/app";export type TRPCContext = { sessionClient: Awaited<ReturnType<typeof getSessionFromRequest>>; prisma: typeof prisma; requestIp: string | null; userAgent: string | null; appRoot: string;};type SessionClient = NonNullable<TRPCContext["sessionClient"]>;type AuthenticatedSessionClient = SessionClient & { user: NonNullable<SessionClient["user"]>;};type AuthenticatedTRPCContext = Omit<TRPCContext, "sessionClient"> & { sessionClient: AuthenticatedSessionClient;};function getIpFromRequest(req: Request | NextRequest): string | null { const xff = req.headers.get("x-forwarded-for"); if (xff) { const first = xff.split(",")[0]?.trim(); if (first) return first; } const realIp = req.headers.get("x-real-ip"); if (realIp) return realIp; return null;}export async function createTRPCContext({ req,}: { req: Request | NextRequest;}): Promise<TRPCContext> { const sessionClient = await getSessionFromRequest(req); return { sessionClient, prisma, requestIp: getIpFromRequest(req), userAgent: req.headers.get("user-agent"), appRoot: APP_ROOT, };}export const t = initTRPC.context<TRPCContext>().create({ transformer: superjson, // Une erreur non prévue (Prisma, réseau, bug) porte un message technique : nom de table, // texte de la requête… En production, le navigateur reçoit un message neutre ; le détail // reste dans les journaux du serveur (onError de la route /api/trpc). errorFormatter({ shape, error }) { if (error.code === "INTERNAL_SERVER_ERROR" && process.env.NODE_ENV === "production") { return { ...shape, message: "Erreur interne du serveur. Réessayez dans un instant." }; } return shape; },});export const router = t.router;export const publicProcedure = t.procedure;const isAuthed = t.middleware(({ ctx, next }) => { const sessionClient = ctx.sessionClient; if (!sessionClient?.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Connexion requise." }); } return next({ ctx: { ...ctx, sessionClient: sessionClient as AuthenticatedSessionClient, } as AuthenticatedTRPCContext, });});export const protectedProcedure = t.procedure.use(isAuthed);👉 Ce qu’il faut comprendre tout de suite :
- le contexte est défini une seule fois
- il est reconstruit à chaque requête
- il est injecté automatiquement dans chaque procédure
💡 Concrètement, ce contexte contient déjà :
- la session (et donc l’utilisateur éventuel)
- s’il est administrateur (calculé à partir de ses groupes)
- l’accès à Prisma
- des informations fiables sur la requête (IP, user-agent)
👉 Ce que cela change est fondamental.
Une procédure ne reçoit plus :
- une donnée brute à traiter
Elle reçoit :
- une donnée
- dans un environnement déjà reconstruit et cohérent
🔭 Pour aller plus loin — le transformer superjson#
Nous avons mentionné en passant superjson. Il mérite un arrêt, parce qu’il est la pièce discrète qui rend possible le typage continu dont nous parlons depuis le début.
Le problème concret : JSON.stringify ne sait pas encoder tout ce que TypeScript sait exprimer. Un objet Date, après un aller-retour JSON, devient une string. Un Map, un Set, un BigInt, une valeur undefined dans un champ — tous perdent leur nature. Sans transformateur, discipline.publicationDate.getTime() planterait dans le navigateur : la date serait arrivée sous forme de texte, alors que le type inféré promettait une Date.
Ce que fait superjson : il sérialise les données en deux parties. D’un côté, les valeurs elles-mêmes, encodées en JSON ordinaire. De l’autre, une petite annotation qui décrit quels types spéciaux existent dans la structure, et où. À la réception, il reconstruit tout fidèlement. Le réseau transporte donc toujours du JSON ; ce qui change, c’est que personne n’a à le décoder à la main, et que rien ne se perd au passage.
Pour que cela marche, les deux extrémités doivent parler la même langue. Le transformateur est déclaré deux fois : côté serveur dans initTRPC (ligne 56 de core.ts, ci-dessus), côté navigateur dans le lien du client (chapitre 10). Si l’un des deux l’oubliait, l’autre recevrait un format qu’il ne sait pas lire.
Résultat : quand le backend renvoie une Date, le client reçoit une Date. Le typage n’est plus une promesse qui se brise au passage du réseau — il est respecté jusqu’à la réception.
C’est le chaînon qui rend crédible la phrase que nous répétons : la donnée reste elle-même du début à la fin.
Dans tRPC, nous ne traitons jamais une requête isolée.
Nous traitons une requête déjà comprise par le système.
💥 Résultat :
- plus de duplication de logique
- plus de vérifications répétées
- plus d’incohérences entre routes
👉 Le backend ne devine plus le contexte.
👉 Il le possède déjà.
🧠 Mais une question reste en suspens :
quand est-ce que ce contexte est réellement créé ?
Il ne suffit pas de définir createTRPCContext.
Encore faut-il comprendre qui l’appelle.
Parce qu’une fonction, tant qu’elle n’est pas appelée, n’existe pas vraiment. Elle attend.
Dans ce projet, ce rôle est assuré par la route App Router dédiée à tRPC.
// ⚠ Fichier introuvable : apps/web/src/app/api/trpc/[trpc]/route.ts// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.Lecture guidée du point d’entrée#
Comment une requête tRPC entre dans le système
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
// ⚠ Fichier introuvable : apps/web/src/app/api/trpc/[trpc]/route.ts// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.👉 C’est ici que tout s’assemble.
À chaque requête :
- la route
/api/trpcreçoit l’appel fetchRequestHandlerprend la main- et appelle automatiquement
createTRPCContext(req)
💡 Autrement dit :
le contexte n’est pas appelé manuellement
il est injecté systématiquement à chaque requête tRPC
Il n’y a pas d’exception possible. Aucune procédure ne peut s’exécuter sans contexte. Aucune procédure ne peut contourner cette étape. C’est une garantie architecturale, pas une convention.
Et c’est exactement ce qui garantit :
- une cohérence globale
- aucune duplication
- un backend toujours exécuté dans un environnement fiable
Chaque appel tRPC déclenche :
👉 création du contexte → exécution de la procédure
Le contexte n’est jamais oublié.
Il fait partie du flux.
Étape 3 — La procédure (pas une route)#
Une fois le contexte prêt, nous entrons dans une procédure.
Procédure (pseudo-code)
const createUser = procedure({ input, ctx,}) => { return ctx.prisma.user.create(input)}💡 Différence majeure :
- ❌ ce n’est pas une URL
- ❌ ce n’est pas une route
👉 c’est une capacité du système
Pour bien sentir la différence, comparons deux façons de décrire la même chose.
Avec REST, nous décrivons une action sur une ressource : POST /api/users signifie “ajouter un utilisateur à la collection des utilisateurs”. C’est une pensée géographique — il y a une ressource quelque part, et nous agissons dessus.
Avec tRPC, nous décrivons une capacité : user.create(data) signifie “le système sait créer un utilisateur à partir de ces données”. C’est une pensée fonctionnelle — il y a une compétence dans le système, et nous l’invoquons.
Ce déplacement mental est important. Il déplace notre attention de la topologie du réseau vers les intentions métier.
Chaque procédure :
- reçoit une donnée validée
- reçoit un contexte fiable
- exécute la logique métier
- retourne une réponse typée
Le flux complet (sans rupture)#
Scroller horizontalement si nécessaire
🧠 Ce qu’il faut voir :
- plus de JSON intermédiaire
- plus de reconstruction manuelle
- plus de perte de typage
👉 la donnée reste elle-même du début à la fin
Le vrai changement#
Ce n’est pas juste une autre façon d’appeler un backend.
💥 C’est une autre façon de le structurer.
tRPC remplace le modèle “API + JSON” par
👉 “contexte (core.ts) + procédures + données typées”.
Où regarder ensuite#
À ce stade, nous avons :
- une carte claire
- un flux continu
- un point d’ancrage concret : core.ts
🚀 Prochaine étape :
👉 comprendre en détail comment core.ts :
- crée le contexte
- distingue
publicProcedureetprotectedProcedure - requalifie le type du contexte
C’est là que le backend prend vraiment forme, et ce sera l’objet du chapitre 6.
👉 Où nous en sommes : nous avons redessiné la carte, et le backend a maintenant un point d’ancrage clair — core.ts, traversé à chaque requête par fetchRequestHandler. Mais cette carte reste encore géographique : elle montre où les choses se trouvent. Il nous manque la sémantique — ce que chaque pièce est, par opposition à ce que les autres sont.
À suivre#
Nous savons où naît le contexte et par où entre chaque requête. Il reste à comprendre ce que chaque pièce est : ce qui relève de l’environnement, ce qui relève de l’intention, et ce qu’est vraiment un « router ».
Chapitre suivant : contexte et intention. Les deux dimensions d’une requête, le routeur comme regroupement de procédures, et les trente domaines du projet assemblés dans appRouter.