L’écart entre un frontend typé et un backend typé est l’endroit où naissent la plupart des bugs runtime : vous définissez une forme côté serveur, vous la redéfinissez côté client, et les deux divergent dès que quelqu’un livre un changement. REST vous oblige à écrire ce contrat deux fois en espérant que les deux copies restent synchronisées. GraphQL comble l’écart mais impose un langage de schéma, une étape de build et de la génération de code pour aligner les types. tRPC supprime le contrat purement et simplement. Vous écrivez votre API sous forme de fonctions TypeScript classiques, et le client déduit leurs entrées et sorties directement depuis le code serveur, sans langage de schéma, sans types générés et sans étape de build entre les deux.
Procédures et routeurs : le serveur comme fonctions
Une API tRPC est un arbre de procédures, chacune étant une fonction avec une entrée déclarée et un corps query ou mutation. Vous les regroupez dans un routeur, et ce routeur devient la source de vérité unique pour toute la surface de l’API.
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
userById: t.procedure.input(z.object({ id: z.string() })).query(({ input }) => getUser(input.id)),
createUser: t.procedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(({ input }) => saveUser(input)),
});
export type AppRouter = typeof appRouter;
query lit, mutation écrit, et le type AppRouter exporté transporte la forme de chaque procédure. Ce simple export typeof est tout le contrat que le client va consommer.
Validation des entrées avec Zod
L’appel .input() accepte n’importe quel validateur, et Zod s’impose naturellement parce qu’il produit à la fois une garde runtime et un type statique à partir d’une seule déclaration. Une requête malformée est rejetée avant l’exécution du handler, et input arrive déjà typé à l’intérieur.
export const postRouter = t.router({
list: t.procedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
})
)
.query(({ input }) => {
// input.limit est un number, input.cursor est string | undefined
return getPosts(input);
}),
});
Il n’y a pas de DTO séparé à maintenir synchronisé ni de parsing manuel. Le validateur est le type, donc la valeur qui atteint votre logique a déjà passé son contrat. Ce schéma Zod est une garde d’entrée par procédure, pas un langage de schéma d’API : rien ne génère les types du client à partir de là, et le contrat client vient toujours de la seule inférence TypeScript.
Inférence de types côté client
Le client se construit à partir du seul type AppRouter, importé avec import type pour qu’aucun code serveur ne parte dans le navigateur. Chaque procédure devient alors accessible avec autocomplétion complète et son type de retour inféré de bout en bout.
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './router';
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc' })],
});
const user = await trpc.userById.query({ id: '1' });
// user est typé depuis le retour serveur, sans étape de codegen
Renommez un champ côté serveur et le client cesse de compiler dans la foulée. L’erreur de type devient le test du contrat, attrapée au build plutôt qu’en production.
Intégration avec React Query
Côté frontend, tRPC enveloppe TanStack Query pour que chaque procédure devienne un hook entièrement typé. Vous obtenez le cache, le refetch et l’état des mutations gratuitement, avec une clé dérivée automatiquement de la procédure et de son entrée.
import { trpc } from './trpc';
function UserProfile({ id }: { id: string }) {
const { data, isLoading } = trpc.userById.useQuery({ id });
if (isLoading) return <Spinner />;
return <h1>{data.name}</h1>;
}
data est typé comme la valeur de retour de la procédure, et la clé de requête est dérivée de l’entrée, donc l’invalidation du cache reste correcte sans que vous écriviez la moindre clé sous forme de chaîne à la main.
Middleware et procédures protégées
Les préoccupations transverses comme l’authentification vivent dans un middleware, qui s’exécute avant la procédure et peut affiner le contexte qui la traverse. Construire un protectedProcedure une fois donne à chaque route protégée un utilisateur restreint et non nul, sans répéter la vérification.
import { TRPCError } from '@trpc/server';
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { user: ctx.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
Comme next() renvoie un contexte affiné, TypeScript sait que ctx.user est défini dans toute procédure construite sur protectedProcedure. La règle d’autorisation et sa garantie de type sont la même ligne de code.
Adaptateurs : où tourne tRPC
tRPC n’est lié à aucun serveur en particulier. Un même routeur se monte sur le runtime que vous utilisez via un adaptateur, d’un route handler Next.js à un serveur Node autonome ou une fonction edge, sans toucher aux procédures elles-mêmes.
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/router';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({ user: null }),
});
export { handler as GET, handler as POST };
Le même appRouter alimente une fonction serverless ici et un serveur longue durée ailleurs. Vous écrivez l’API une fois et choisissez le transport ensuite.
Conclusion
tRPC est la plus petite distance possible entre un serveur typé et un client typé : sans schéma, sans code généré, juste l’inférence TypeScript qui fait le travail. Il paie surtout sur les projets full-stack où une seule équipe possède les deux bouts et où un type partagé vaut plus qu’un contrat agnostique au langage. Quand vous devez exposer une API publique à des consommateurs que vous ne maîtrisez pas, REST ou GraphQL gardent tout leur intérêt. À l’intérieur d’un monorepo TypeScript, tRPC transforme la frontière client-serveur d’une source de bugs runtime en quelque chose que le compilateur se contente d’imposer.