Nehos Groupe

Ce qu'il faut retenir

tRPC élimine la couche de contrat API manuel entre frontend et backend Next.js : les types TypeScript sont partagés de bout en bout, sans génération de code, sans OpenAPI spec. Un changement de signature côté serveur devient immédiatement une erreur de compilation côté client.

Le setup avec Next.js App Router nécessite une configuration spécifique — tRPC s'intègre via des Route Handlers et un client React Query côté client, tandis que les Server Components peuvent appeler les procedures directement sans HTTP.

tRPC est le bon choix pour les applications full-stack TypeScript où frontend et backend vivent dans le même repo ou monorepo. Il n'est pas adapté aux APIs publiques consommées par des tiers, ni aux équipes avec des stacks frontend/backend hétérogènes.

tRPC avec Next.js App Router 2026 : type-safety E2E guide complet

Tribune de Chokri Siala, CTO Nehos — setup App Router, routers, mutations, middleware authentification, validation Zod, gestion d'erreurs, tests et les cas où tRPC n'est pas le bon choix.

Adapté à toute taille de structure

Artisan
Startup
PME / TPE
ETI
Grand Groupe
C
Chokri Siala
··next-headless

#Pourquoi tRPC change la façon de construire des APIs en 2026

La question qui revenait dans toutes nos revues de code en 2024 : pourquoi continuons-nous à écrire du code pour décrire des APIs que nous allons consommer nous-mêmes ? REST avec OpenAPI génère des types. GraphQL génère des types. Les deux nécessitent une étape de génération, une toolchain supplémentaire, un contrat à maintenir synchronisé.

tRPC résout ce problème d'une façon différente : il supprime le concept d'API au sens traditionnel. Le serveur expose des procedures TypeScript — des fonctions avec leurs types d'entrée et de sortie. Le client consomme ces procedures comme si c'était des fonctions locales. Il n'y a pas de contrat à maintenir parce qu'il n'y a qu'une seule source de vérité : le code TypeScript.

En 2026, cette approche est mature. tRPC v11 est stable, son intégration avec Next.js App Router est documentée et testée en production sur des centaines de projets. Le State of JS 2025 classe tRPC parmi les outils avec le taux de satisfaction le plus élevé dans la catégorie API layers — 92% des développeurs qui l'ont utilisé déclarent vouloir continuer à l'utiliser.

#Le problème que tRPC résout vraiment

Prenons un exemple concret. Votre équipe ajoute un champ companySize à l'endpoint /api/users/:id. Avec REST classique, ce changement nécessite :

  1. Modifier le handler API côté serveur
  2. Mettre à jour le type User dans le fichier de types partagés (si vous en avez un)
  3. Mettre à jour la spec OpenAPI (si elle est maintenue)
  4. Regénérer les types client (si vous utilisez openapi-typescript)
  5. Mettre à jour le code frontend qui consomme l'endpoint

Avec tRPC, ce changement se réduit à :

  1. Modifier la procedure tRPC côté serveur — ajouter companySize au schéma Zod et à la valeur retournée
  2. Le code frontend qui n'utilise pas companySize continue de compiler. Le code qui en a besoin peut y accéder immédiatement avec autocomplétion.

Aucune génération de code. Aucun contrat intermédiaire. Le compilateur TypeScript est le contrat.

#Ce que signifie concrètement "type-safety E2E"

La "type-safety end-to-end" n'est pas un argument marketing — c'est une propriété vérifiable. Avec tRPC, si vous modifiez le type de retour d'une procedure sur le serveur, toutes les utilisations côté client qui ne correspondent plus au nouveau type généreront des erreurs de compilation. Cette garantie couvre :

  • Les paramètres d'entrée des queries et mutations
  • Les types de retour
  • Les codes d'erreur spécifiques
  • Les types des subscriptions WebSocket

Cette propriété transforme les revues de code : les changements d'API sont détectables statiquement, pas seulement en runtime.


#tRPC vs REST vs GraphQL : comparatif pour projets Next.js B2B

Chaque approche a un domaine d'application naturel. Le choix dépend moins de la qualité intrinsèque de chaque solution que de votre contexte.

#REST + OpenAPI : le standard interopérable

REST reste le choix par défaut pour les APIs B2B qui doivent être consommées par des tiers — partenaires, intégrateurs, applications mobiles indépendantes. Le contrat OpenAPI est un standard d'industrie : n'importe quel écosystème peut générer un client typé depuis une spec OpenAPI.

Pour quand : API publique, API consommée par des équipes non-TypeScript, équipe frontend et backend séparées dans des repos distincts, besoin de versionning d'API explicite (v1, v2).

Limites : overhead de maintenance du contrat, étape de génération de code, risque de désynchronisation spec/implémentation sans CI strict.

#GraphQL : la flexibilité au prix de la complexité

GraphQL brille pour les APIs avec des relations de données complexes et des consommateurs aux besoins hétérogènes — le frontend peut requêter exactement ce dont il a besoin, ni plus ni moins. C'est l'approche de référence pour les plateformes avec plusieurs clients (web, mobile, partenaires).

Pour quand : données fortement relationnelles, plusieurs clients avec des besoins de sélection de champs différents, API exposée à des partenaires externes qui bénéficient de la flexibilité des queries.

Limites : complexité de mise en place (schema, resolvers, codegen), N+1 problem à gérer avec DataLoader, courbe d'apprentissage équipe. Sur un projet Next.js full-stack sans clients externes, GraphQL est souvent overengineered.

#tRPC : la productivité maximale sur les projets full-stack TypeScript

Pour quand : application full-stack TypeScript avec frontend et backend dans le même repo ou monorepo, API consommée uniquement par votre propre frontend, équipe 100% TypeScript, projet B2B où la vitesse de livraison est critique.

Limites : non adapté aux APIs publiques (pas de spec OpenAPI native), ne fonctionne que dans des écosystèmes TypeScript, couplage fort entre frontend et backend (ce qui est une feature pour les projets internes, mais une contrainte pour les APIs partagées).

#Tableau comparatif pour Next.js B2B 2026

CritèreREST + OpenAPIGraphQLtRPC
Type-safety E2EVia codegenVia codegenNative
Courbe d'apprentissageFaibleÉlevéeMoyenne
API publique/tierceOuiOuiNon
Vitesse de développementMoyenneFaibleÉlevée
Flexibilité des queriesFixeTrès élevéeFixe
Maintenance contratÉlevéeÉlevéeNulle
Server Components supportManuelManuelNatif
Abonnements temps réelVia SSE/WS manuelNatifNatif

#Setup tRPC avec Next.js App Router et React Server Components

Le setup tRPC avec App Router diffère significativement de Next.js Pages Router. L'architecture App Router offre une intégration plus profonde via les Server Components.

#Installation

pnpm add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
pnpm add -D @types/node

#Structure des fichiers recommandée

src/
├── server/
│   └── trpc/
│       ├── init.ts          # Initialisation tRPC + context
│       ├── routers/
│       │   ├── _app.ts      # Router racine (merge)
│       │   ├── users.ts
│       │   └── projects.ts
│       └── trpc.ts          # Helpers exportés (procedure, router)
├── app/
│   └── api/
│       └── trpc/
│           └── [trpc]/
│               └── route.ts # Route Handler Next.js
└── lib/
    └── trpc/
        ├── client.ts        # Client tRPC React Query
        ├── server.ts        # Caller tRPC pour Server Components
        └── provider.tsx     # TRPCReactProvider

#Initialisation du contexte tRPC

// src/server/trpc/init.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { cache } from 'react';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import superjson from 'superjson';
import { ZodError } from 'zod';

export type Context = {
  session: Awaited<ReturnType<typeof getServerSession>> | null;
  headers: Headers;
};

export const createTRPCContext = cache(async (opts: { headers: Headers }): Promise<Context> => {
  const session = await getServerSession(authOptions);
  return {
    session,
    headers: opts.headers,
  };
});

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

export const { router, procedure: publicProcedure, middleware } = t;
export const createCallerFactory = t.createCallerFactory;

#Route Handler Next.js App Router

// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/trpc/routers/_app';
import { createTRPCContext } from '@/server/trpc/init';
import { NextRequest } from 'next/server';

const handler = (req: NextRequest) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createTRPCContext({ headers: req.headers }),
    onError({ error, path }) {
      if (process.env.NODE_ENV === 'development') {
        console.error(`tRPC error on ${path}:`, error);
      }
    },
  });

export { handler as GET, handler as POST };

#Caller pour Server Components

Avec App Router, les Server Components peuvent appeler les procedures tRPC directement sans passer par HTTP — une optimisation de performance majeure :

// src/lib/trpc/server.ts
import { createCallerFactory } from '@/server/trpc/init';
import { appRouter } from '@/server/trpc/routers/_app';
import { createTRPCContext } from '@/server/trpc/init';
import { cache } from 'react';
import { headers } from 'next/headers';

const createCaller = createCallerFactory(appRouter);

export const getServerCaller = cache(async () => {
  const heads = await headers();
  const ctx = await createTRPCContext({ headers: heads });
  return createCaller(ctx);
});

Dans un Server Component :

// src/app/dashboard/page.tsx
import { getServerCaller } from '@/lib/trpc/server';

export default async function DashboardPage() {
  const trpc = await getServerCaller();
  // Appel direct, pas de réseau — même processus
  const projects = await trpc.projects.list({ limit: 10 });

  return <ProjectsList projects={projects} />;
}

Cette architecture exploite le paradigme des Server Components React 2026 : le data fetching se produit côté serveur, sans roundtrip HTTP, avec le plein support du cache React.


#Définir les routers et procedures : query, mutation, subscription

#Structure d'un router

// src/server/trpc/routers/projects.ts
import { z } from 'zod';
import { router, publicProcedure } from '../init';
import { protectedProcedure } from '../middlewares/auth';

const projectSchema = z.object({
  id: z.string().cuid(),
  name: z.string().min(2).max(100),
  status: z.enum(['draft', 'active', 'archived']),
  createdAt: z.date(),
  updatedAt: z.date(),
});

const createProjectInput = z.object({
  name: z.string().min(2).max(100),
  description: z.string().max(500).optional(),
});

export const projectsRouter = router({
  // QUERY : lecture de données
  list: protectedProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(20),
      cursor: z.string().cuid().optional(),
    }))
    .output(z.object({
      items: z.array(projectSchema),
      nextCursor: z.string().cuid().optional(),
    }))
    .query(async ({ ctx, input }) => {
      const items = await ctx.db.project.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        where: { userId: ctx.session.user.id },
        orderBy: { createdAt: 'desc' },
      });

      const nextCursor = items.length > input.limit
        ? items.pop()!.id
        : undefined;

      return { items, nextCursor };
    }),

  // MUTATION : écriture de données
  create: protectedProcedure
    .input(createProjectInput)
    .mutation(async ({ ctx, input }) => {
      return ctx.db.project.create({
        data: {
          ...input,
          userId: ctx.session.user.id,
          status: 'draft',
        },
      });
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ ctx, input }) => {
      const project = await ctx.db.project.findUnique({
        where: { id: input.id },
      });

      if (!project || project.userId !== ctx.session.user.id) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Projet introuvable ou accès non autorisé',
        });
      }

      await ctx.db.project.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

#Router racine avec merge

// src/server/trpc/routers/_app.ts
import { router } from '../init';
import { usersRouter } from './users';
import { projectsRouter } from './projects';
import { analyticsRouter } from './analytics';

export const appRouter = router({
  users: usersRouter,
  projects: projectsRouter,
  analytics: analyticsRouter,
});

export type AppRouter = typeof appRouter;

#Subscriptions WebSocket

tRPC supporte les subscriptions temps réel via WebSocket. Configuration dans init.ts avec un transport WebSocket séparé du transport HTTP — pertinent pour les notifications en temps réel sur les applications B2B Next.js avec tableaux de bord live.

import { observable } from '@trpc/server/observable';
import EventEmitter from 'events';

const ee = new EventEmitter();

export const notificationsRouter = router({
  onNewNotification: protectedProcedure
    .input(z.object({ userId: z.string() }))
    .subscription(({ input }) => {
      return observable<Notification>((emit) => {
        const onNotification = (data: Notification) => {
          if (data.userId === input.userId) {
            emit.next(data);
          }
        };
        ee.on('notification', onNotification);
        return () => ee.off('notification', onNotification);
      });
    }),
});

#Middleware et gestion de l'authentification dans tRPC

Les middlewares tRPC sont des fonctions qui s'exécutent avant les procedures. Ils permettent de centraliser l'authentification, le logging, le rate limiting et tout autre comportement transversal.

#Middleware d'authentification

// src/server/trpc/middlewares/auth.ts
import { TRPCError } from '@trpc/server';
import { middleware, procedure as publicProcedure } from '../init';

const isAuthenticated = middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'Vous devez être connecté pour accéder à cette ressource',
    });
  }

  return next({
    ctx: {
      ...ctx,
      session: ctx.session,       // TypeScript sait maintenant que session est non-null
      user: ctx.session.user,     // User typé et garanti non-null
    },
  });
});

export const protectedProcedure = publicProcedure.use(isAuthenticated);

#Middleware de rôle (RBAC)

// src/server/trpc/middlewares/roles.ts
import { TRPCError } from '@trpc/server';
import { middleware } from '../init';

type Role = 'admin' | 'manager' | 'viewer';

export const requireRole = (requiredRole: Role) =>
  middleware(({ ctx, next }) => {
    const userRole = ctx.session?.user?.role as Role | undefined;

    const roleHierarchy: Record<Role, number> = {
      admin: 3,
      manager: 2,
      viewer: 1,
    };

    if (!userRole || roleHierarchy[userRole] < roleHierarchy[requiredRole]) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: `Rôle requis : ${requiredRole}. Votre rôle : ${userRole ?? 'non défini'}`,
      });
    }

    return next({ ctx });
  });

export const adminProcedure = protectedProcedure.use(requireRole('admin'));
export const managerProcedure = protectedProcedure.use(requireRole('manager'));

#Middleware de logging et performance

// src/server/trpc/middlewares/logger.ts
import { middleware } from '../init';

export const loggerMiddleware = middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const durationMs = Date.now() - start;

  if (result.ok) {
    console.log(`[tRPC] ${type} ${path} — ${durationMs}ms OK`);
  } else {
    console.error(`[tRPC] ${type} ${path} — ${durationMs}ms ERROR`, result.error);
  }

  return result;
});

La composition de middlewares dans tRPC est explicite et statiquement typée — contrairement aux middlewares Express où les types du contexte enrichis ne sont pas propagés au handler suivant. L'intégration avec les patterns de l'App Router Next.js vs Pages Router est directe : un seul middleware pour tous les endpoints tRPC, pas de duplication par route.


→ Vous évaluez vos options ? Utilisez notre estimateur de budget en ligne pour obtenir une fourchette en 2 minutes, ou consultez nos tarifs détaillés.

#Validation avec Zod : types partagés frontend/backend

Zod est le partenaire naturel de tRPC. Les schémas Zod servent à la fois de validation runtime et de source des types TypeScript — une seule définition pour les deux usages.

#Schémas partagés dans un monorepo

Dans l'architecture monorepo Turborepo recommandée pour les projets B2B, les schémas Zod vivent dans un package partagé libs/schemas :

// libs/schemas/src/user.ts
import { z } from 'zod';

export const UserRoleSchema = z.enum(['admin', 'manager', 'viewer']);
export type UserRole = z.infer<typeof UserRoleSchema>;

export const UserSchema = z.object({
  id: z.string().cuid(),
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: UserRoleSchema,
  createdAt: z.date(),
});
export type User = z.infer<typeof UserSchema>;

export const CreateUserInput = z.object({
  email: z.string().email('Email invalide'),
  name: z.string().min(2, 'Nom trop court').max(100, 'Nom trop long'),
  role: UserRoleSchema.default('viewer'),
});
export type CreateUserInput = z.infer<typeof CreateUserInput>;

export const UpdateUserInput = CreateUserInput.partial().extend({
  id: z.string().cuid(),
});
export type UpdateUserInput = z.infer<typeof UpdateUserInput>;

#Réutilisation frontend : validation de formulaire

Le même schéma Zod utilisé dans la procedure tRPC peut être réutilisé dans le formulaire React avec react-hook-form :

// src/components/forms/CreateUserForm.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateUserInput } from '@monorepo/schemas/user';
import { api } from '@/lib/trpc/client';

export function CreateUserForm() {
  const form = useForm<CreateUserInput>({
    resolver: zodResolver(CreateUserInput),
    defaultValues: { role: 'viewer' },
  });

  const createUser = api.users.create.useMutation({
    onSuccess: () => {
      form.reset();
    },
  });

  const onSubmit = (data: CreateUserInput) => {
    createUser.mutate(data);
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* champs du formulaire */}
    </form>
  );
}

La validation s'applique deux fois : côté client via zodResolver (UX immédiate), côté serveur dans la procedure tRPC (sécurité garantie). Un seul schéma, deux niveaux de protection — c'est précisément l'approche préconisée dans notre guide sur la validation et les Structured Outputs.

#Transformations et coercions

Zod gère les transformations de type qui sont courantes dans les APIs :

const queryInput = z.object({
  // Coerce string -> number pour les query params URL
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(20),
  // Date depuis string ISO
  startDate: z.coerce.date().optional(),
  // Trim et lowercase sur les strings
  search: z.string().trim().toLowerCase().optional(),
});

#Gestion des erreurs et types d'erreurs customisés

tRPC propose un système d'erreurs structuré qui va bien au-delà des codes HTTP.

#Codes d'erreur tRPC

tRPC mappe ses codes d'erreur vers des statuts HTTP standards :

Code tRPCStatut HTTPCas d'usage
BAD_REQUEST400Input invalide non attrapé par Zod
UNAUTHORIZED401Non authentifié
FORBIDDEN403Authentifié mais non autorisé
NOT_FOUND404Ressource introuvable
CONFLICT409Conflit (email déjà utilisé)
PRECONDITION_FAILED412Précondition métier non remplie
UNPROCESSABLE_CONTENT422Données valides syntaxiquement mais invalides métier
TOO_MANY_REQUESTS429Rate limiting
INTERNAL_SERVER_ERROR500Erreur serveur inattendue

#Erreurs métier typées

Pour les erreurs métier complexes, le pattern recommandé est de créer des classes d'erreur spécifiques :

// src/server/errors/business-errors.ts
import { TRPCError } from '@trpc/server';

export class InsufficientCreditsError extends TRPCError {
  constructor(required: number, available: number) {
    super({
      code: 'PRECONDITION_FAILED',
      message: `Crédits insuffisants. Requis : ${required}, disponibles : ${available}`,
      cause: { required, available, type: 'INSUFFICIENT_CREDITS' },
    });
  }
}

export class ProjectLimitReachedError extends TRPCError {
  constructor(limit: number) {
    super({
      code: 'PRECONDITION_FAILED',
      message: `Limite de projets atteinte (${limit} projets maximum sur votre plan)`,
      cause: { limit, type: 'PROJECT_LIMIT_REACHED' },
    });
  }
}

#Gestion côté client avec React Query

// src/components/ProjectCreator.tsx
'use client';

import { api } from '@/lib/trpc/client';
import { TRPCClientError } from '@trpc/client';
import type { AppRouter } from '@/server/trpc/routers/_app';

export function ProjectCreator() {
  const createProject = api.projects.create.useMutation({
    onError(error) {
      // Type-safe : error est typé selon AppRouter
      if (error instanceof TRPCClientError) {
        const cause = error.data?.cause as { type?: string } | undefined;

        if (cause?.type === 'PROJECT_LIMIT_REACHED') {
          // Afficher un upsell vers le plan supérieur
          showUpgradeModal();
          return;
        }

        // Erreurs Zod structurées
        if (error.data?.zodError) {
          const fieldErrors = error.data.zodError.fieldErrors;
          // Mapper les erreurs vers les champs du formulaire
        }
      }
    },
  });
  // ...
}

La gestion d'erreurs typée est l'un des arguments les plus solides pour tRPC dans un contexte B2B : les erreurs métier spécifiques sont détectables statiquement côté client, pas seulement en lisant la documentation.


#Testing des routes tRPC : unit tests et intégration

Le testing de tRPC bénéficie de sa nature TypeScript-first : les procedures sont des fonctions pures testables sans mocking HTTP.

#Unit tests avec le caller direct

// src/server/trpc/routers/projects.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createCallerFactory } from '../init';
import { appRouter } from './_app';
import { createMockContext } from '@/tests/helpers/mock-context';

const createCaller = createCallerFactory(appRouter);

describe('projectsRouter', () => {
  describe('create', () => {
    it('crée un projet pour un utilisateur authentifié', async () => {
      const ctx = createMockContext({
        session: {
          user: { id: 'user-123', email: 'test@example.com', role: 'manager' },
          expires: '2027-01-01',
        },
      });

      const caller = createCaller(ctx);

      const project = await caller.projects.create({
        name: 'Projet de test',
        description: 'Description du projet',
      });

      expect(project.name).toBe('Projet de test');
      expect(project.status).toBe('draft');
      expect(project.userId).toBe('user-123');
    });

    it('rejette la création sans authentification', async () => {
      const ctx = createMockContext({ session: null });
      const caller = createCaller(ctx);

      await expect(
        caller.projects.create({ name: 'Projet' })
      ).rejects.toThrow('UNAUTHORIZED');
    });

    it('valide les inputs avec Zod', async () => {
      const ctx = createMockContext({ session: { user: { id: 'u-1' } } });
      const caller = createCaller(ctx);

      await expect(
        caller.projects.create({ name: 'x' }) // trop court (min 2)
      ).rejects.toThrow();
    });
  });
});

#Helper de contexte mock

// src/tests/helpers/mock-context.ts
import { vi } from 'vitest';
import type { Context } from '@/server/trpc/init';

export function createMockContext(overrides: Partial<Context> = {}): Context {
  return {
    session: null,
    headers: new Headers(),
    db: {
      project: {
        findMany: vi.fn().mockResolvedValue([]),
        findUnique: vi.fn().mockResolvedValue(null),
        create: vi.fn().mockImplementation((args) => ({
          id: 'proj-mock-1',
          ...args.data,
          createdAt: new Date(),
          updatedAt: new Date(),
        })),
        delete: vi.fn().mockResolvedValue(undefined),
      },
    },
    ...overrides,
  };
}

#Tests d'intégration avec le Route Handler

Pour les tests d'intégration qui valident le transport HTTP, on peut utiliser les utilitaires de testing de @trpc/server :

// src/tests/integration/trpc-http.test.ts
import { describe, it, expect } from 'vitest';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import { appRouter } from '@/server/trpc/routers/_app';
import type { AppRouter } from '@/server/trpc/routers/_app';

describe('tRPC HTTP integration', () => {
  it('effectue un batch de queries', async () => {
    const server = createHTTPServer({
      router: appRouter,
      createContext: () => createMockContext({
        session: { user: { id: 'u-1', email: 'test@test.com', role: 'admin' } },
      }),
    });

    server.listen(0);
    const { port } = server.server.address() as { port: number };

    const client = createTRPCClient<AppRouter>({
      links: [
        httpBatchLink({
          url: `http://localhost:${port}`,
          transformer: superjson,
        }),
      ],
    });

    const result = await client.projects.list.query({ limit: 5 });
    expect(result.items).toBeInstanceOf(Array);

    server.server.close();
  });
});

Cette approche de testing s'intègre naturellement avec Playwright pour les tests E2E : les tests unitaires couvrent la logique tRPC, les tests E2E couvrent le comportement utilisateur de bout en bout. La qualité code maintenue par les standards Nehos exige les deux niveaux sur les projets B2B.


#Performance et limitations : quand tRPC n'est pas le bon choix

tRPC est un outil excellent dans son domaine. Mais comme tout outil, il a des limites réelles qu'il faut connaître avant d'architecturer un projet.

#Limitations techniques

Ecosystème TypeScript exclusif : tRPC ne fonctionne que dans un environnement TypeScript. Si vous avez une app mobile en Swift, une intégration en Python, ou un partenaire qui consomme votre API en Go, tRPC ne peut pas leur exposer une interface typée. Pour ces cas, REST + OpenAPI ou GraphQL restent incontournables.

Pas de spec OpenAPI native : contrairement à REST, tRPC ne génère pas de documentation d'API lisible par des outils standards. Il existe des adapters comme trpc-openapi qui permettent d'exposer des endpoints REST depuis des procedures tRPC — utile pour les cas hybrides. Mais le workflow de documentation reste plus complexe qu'avec une API REST native.

Couplage fort frontend/backend : le contrat implicite est une feature pour les équipes internes, mais un risque pour les projets où le backend peut être réutilisé par plusieurs frontends non-TypeScript. Si vous anticipez d'exposer votre backend à des consommateurs externes dans 2 ans, concevez votre API REST dès maintenant.

Pas de cache HTTP natif : contrairement aux requêtes GET REST qui bénéficient du cache HTTP (CDN, navigateur), les queries tRPC via httpBatchLink utilisent des POST par défaut — non cacheables par les CDN. Pour les données fortement cacheable, il faut configurer httpLink sur les queries spécifiques ou utiliser le mécanisme de cache React / Next.js au niveau application.

#Limitations organisationnelles

Monorepo ou repos couplés requis : tRPC nécessite que le type AppRouter soit accessible côté client. En pratique, cela signifie un monorepo ou une dépendance de package entre les repos. Si vos équipes frontend et backend ont des processus de release totalement indépendants, le couplage tRPC peut devenir une friction organisationnelle.

Courbe d'apprentissage pour les développeurs junior : la magie du type-inference tRPC peut être déroutante pour les développeurs moins familiers avec TypeScript avancé. Les erreurs de type au niveau des middlewares ou des context-merging peuvent être cryptiques. Un développeur senior doit généralement poser les fondations.

#Quand choisir autre chose

Préférez REST + OpenAPI si : API publique ou consommée par des tiers, équipes frontend/backend séparées avec releases indépendantes, besoin de versionning d'API explicite, stack non-TypeScript.

Préférez GraphQL si : données fortement relationnelles, multiples clients avec besoins de sélection différents, fédération de plusieurs services, partenaires qui bénéficient de la flexibilité des queries.

tRPC est le bon choix si : application full-stack TypeScript dans un monorepo, API consommée uniquement par votre propre frontend, équipe qui valorise la productivité et la sécurité de type au détriment de l'interopérabilité externe, projet où la vélocité de développement est un critère clé.

#Impact sur les performances Next.js

Avec App Router, le pattern optimal est d'utiliser le caller direct dans les Server Components (zéro overhead HTTP) et le client React Query uniquement pour les mutations et les queries qui nécessitent une réactivité côté client. Cette stratégie duale maximise les performances : les données initiales sont chargées côté serveur sans latence réseau, les interactions utilisateur passent par le client HTTP standard.

Pour les projets qui doivent aussi exposer une API publique, l'approche hybride REST + tRPC est viable : les endpoints publics sont des Route Handlers Next.js classiques, les endpoints internes utilisent tRPC. Les patterns d'API-first design pour B2B restent applicables — tRPC et REST ne sont pas mutuellement exclusifs dans une même application.

#Cas d'usage Nehos en production

Sur les projets clients Nehos en refonte B2B, tRPC est systématiquement recommandé pour les portails clients et back-offices Next.js où l'équipe de développement est unique et le frontend est exclusivement Next.js. Sur les projets avec des contraintes d'interopérabilité externe — notamment les intégrations avec des CMS headless comme Payload CMS qui exposent leur propre API REST — l'approche hybride est préférée.

La maturité de tRPC en 2026 permet de l'adopter sur des projets de production sans réserve technique — à condition de choisir le bon périmètre d'application.

Questions & Réponses

Questions fréquentes sur tRPC avec Next.js TypeScript

Oui, tRPC v11 est pleinement compatible avec Next.js App Router, y compris avec Next.js 16. L'intégration se fait via des Route Handlers (fichiers `route.ts` dans le dossier `app/api/trpc/[trpc]/`) et le nouveau mécanisme de cache React. Les Server Components peuvent appeler les procedures tRPC directement via un caller sans passer par HTTP, ce qui est une optimisation de performance majeure. Le seul point de vigilance : la configuration du contexte tRPC doit utiliser `cache()` de React pour éviter les recréations inutiles par requête.
Dans un monorepo Turborepo, le type `AppRouter` est exporté depuis le package serveur (par exemple `apps/api` ou `libs/trpc-server`) et importé dans les apps frontend comme type uniquement : `import type { AppRouter } from '@monorepo/api'`. Cette importation de type n'entraîne aucun code serveur dans le bundle client — TypeScript erase les imports de type à la compilation. Le package serveur qui exporte `AppRouter` ne doit exporter que des types, pas des implémentations avec des dépendances Node.js qui ne doivent pas se retrouver dans le bundle navigateur.
Parfaitement. tRPC est agnostique de la couche de données — il fonctionne avec Prisma, Drizzle, Kysely, ou n'importe quel ORM ou client de base de données. La pratique recommandée est d'injecter le client Prisma dans le contexte tRPC (dans `createTRPCContext`) pour qu'il soit disponible dans toutes les procedures via `ctx.db`. Prisma et tRPC se complètent particulièrement bien : les types Prisma peuvent être réutilisés comme types de base des schémas Zod, et les validations Zod sur les inputs protègent les requêtes Prisma des injections.
La migration progressive est la meilleure approche — ne migrez pas tout en une fois. La stratégie recommandée est de créer le router tRPC en parallèle des endpoints REST existants, puis de migrer page par page, en commençant par les pages les moins critiques. L'outil `trpc-openapi` permet une période de transition hybride : vos procedures tRPC exposent simultanément une interface REST (pour les clients existants) et une interface tRPC (pour le nouveau code). Une fois tous les consommateurs REST migrés, supprimez l'adapter openapi. Cette approche permet une migration sur plusieurs sprints sans rupture de service.
Dans le contexte d'un projet full-stack Next.js, tRPC avec le caller direct dans les Server Components est plus performant qu'une API REST : les données sont récupérées dans le même processus Node.js, sans serialisation/deserialisation HTTP ni latence réseau. Pour les calls depuis le navigateur (Client Components), les performances sont comparables à REST — le transport HTTP est similaire, avec un léger overhead du `httpBatchLink` qui groupe plusieurs calls en une seule requête (bénéfice net positif pour la plupart des cas). Le cache React intégré à App Router fonctionne avec les calls serveur tRPC, permettant la déduplication automatique des requêtes identiques dans un même cycle de rendu.
tRPC v11 supporte les uploads de fichiers via FormData, mais avec des nuances. La mutation doit être configurée avec `contentType: 'multipart/form-data'` côté client, et le context doit exposer la `Request` brute pour accéder au FormData côté serveur. Pour les uploads volumineux (fichiers > 10 Mo), l'approche recommandée est le pattern "presigned URL" : tRPC génère une URL signée (S3, OVH Object Storage) que le frontend utilise pour uploader directement vers le stockage objet, sans passer par le serveur Node.js. Cette approche est plus scalable et évite de charger les fichiers en mémoire du serveur.
Les erreurs TypeScript tRPC peuvent être cryptiques, surtout dans les middlewares. Techniques recommandées : (1) activer `strict: true` dans tsconfig dès le départ — les erreurs détectées tôt sont moins complexes ; (2) utiliser `.output()` sur les procedures pour forcer TypeScript à valider le type de retour — cela transforme les erreurs runtime en erreurs compile-time ; (3) typer explicitement le context dans les middlewares plutôt que de s'appuyer sur l'inference — `next<{ user: User }>({ ctx: { ...ctx, user } })` ; (4) activer les diagnostics TypeScript dans votre IDE (VSCode, Cursor) avec `TypeScript: Restart TS Server` quand les types semblent désynchronisés. Les erreurs sur les routers mergés sont souvent dues à des types de contexte incompatibles entre routers — vérifiez que tous les routers utilisent le même type de contexte de base.
Réserver un audit