Skip to content
Learni
Voir tous les tutoriels
Développement Fullstack

Comment implémenter tRPC dans Next.js en 2026

Read in English

Introduction

En 2026, tRPC est l'outil incontournable pour les APIs end-to-end type-safe en TypeScript. Contrairement aux APIs REST ou GraphQL traditionnelles, tRPC compile vos procédures serveur en types inférés côté client, éliminant les erreurs runtime dues à des mismatches de types. Imaginez une API où chaque requête est validée à la compilation : pas de any, pas de JSON.parse foireux, et une autocomplétion parfaite dans votre IDE.

Ce tutoriel intermediate vous guide pour intégrer tRPC v11+ dans Next.js 15+ avec App Router. Nous construirons une app todo complète : queries pour lister les tâches, mutations pour CRUD, le tout avec React Query pour le caching et l'optimistic updates. Résultat : une API scalable, rapide et zéro-config pour l'auth ou les middlewares. Parfait pour booster la productivité en full-stack TS. Prêt à passer au niveau supérieur ? (128 mots)

Prérequis

  • Node.js 20+ et npm/yarn/pnpm
  • Connaissances en Next.js App Router et TypeScript
  • Familiarité avec React Query (TanStack Query)
  • Un éditeur comme VS Code avec ESLint/Prettier configurés
  • 10 minutes pour un projet fonctionnel

Installation du projet Next.js + tRPC

terminal
npx create-next-app@latest mon-app-trpc --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd mon-app-trpc
npm install @trpc/server@latest @trpc/client@latest @trpc/react-query@latest @trpc/next@latest @tanstack/react-query@latest superjson@latest zod@latest
npm install -D @types/node typescript

Cette commande crée un projet Next.js 15+ optimisé pour App Router et Tailwind. Nous installons tRPC core, client, React integration avec React Query pour le caching, superjson pour la sérialisation avancée (dates, BigInt), zod pour la validation, et les types Node. Lancez npm run dev après pour vérifier.

Structure du projet tRPC

tRPC repose sur un router hiérarchique : un root.ts assemble les sous-routers. Le handler API est unique à /api/trpc/[trpc]. Côté client, un Provider wrappe l'app. Nous utiliserons un store in-memory pour les todos, comme une DB légère. Créez ces dossiers :

  • src/trpc/routers/ pour les procédures
  • src/trpc/ pour root et client

Analogie : tRPC est comme un RPC natif JS, mais type-safe – pas de HTTP overhead inutile.

Créer le router racine et store in-memory

src/trpc/root.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { todoRouter } from './routers/todo';

const t = initTRPC.context<{ todos: { id: number; text: string; done: boolean }[] }>().create({
  transformer: superjson,
  errorFormatter({ shape }) {
    return { ...shape, data: { ...shape.data, zodError: shape.data?.zodError?.fieldErrors } };
  },
});

export const appRouter = t.router({
  todo: todoRouter,
});

export type AppRouter = typeof appRouter;

const todos: { id: number; text: string; done: boolean }[] = [];

// Handler pour dev standalone (optionnel)
createHTTPServer({
  router: appRouter,
  createContext: () => ({ todos }),
}).listen(3001);

console.log('🚀 tRPC standalone server à http://localhost:3001');

Ce fichier définit le tRPC core avec superjson pour une sérialisation parfaite. Le contexte partage le store todos in-memory. appRouter assemble les sous-routers. Nous ajoutons un serveur standalone pour tests isolés (port 3001). Évitez les contextes vides : toujours typer pour l'autocomplétion client.

Définir le router Todo avec query et mutation

src/trpc/routers/todo.ts
import { z } from 'zod';
import { publicProcedure, router } from '../root';

export const todoRouter = router({
  all: publicProcedure
    .query(({ ctx }) => {
      return { todos: ctx.todos };
    }),
  byId: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(({ input, ctx }) => {
      const todo = ctx.todos.find((t) => t.id === input.id);
      if (!todo) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      return { todo };
    }),
  create: publicProcedure
    .input(z.object({ text: z.string().min(1) }))
    .mutation(({ input, ctx }) => {
      const newTodo = { id: Date.now(), text: input.text, done: false };
      ctx.todos.push(newTodo);
      return { todo: newTodo };
    }),
  toggle: publicProcedure
    .input(z.object({ id: z.number() }))
    .mutation(({ input, ctx }) => {
      const todo = ctx.todos.find((t) => t.id === input.id);
      if (!todo) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      todo.done = !todo.done;
      return { todo };
    }),
  delete: publicProcedure
    .input(z.object({ id: z.number() }))
    .mutation(({ input, ctx }) => {
      const index = ctx.todos.findIndex((t) => t.id === input.id);
      if (index === -1) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      ctx.todos.splice(index, 1);
      return { deleted: true };
    }),
});

Chaque procédure utilise Zod pour valider inputs/sorties, inférant les types automatiquement. publicProcedure est sans auth (à étendre avec middlewares). Queries pour lecture, mutations pour écriture avec TRPCError pour erreurs type-safe. Le store muté directement simule une DB ; en prod, utilisez Prisma ou Drizzle.

Handler API Next.js pour tRPC

Point d'entrée unique : toutes les procédures passent par /api/trpc/[trpc]. Cela batch les requêtes et optimise les perf. Le handler fetch utilise le même contexte que notre store.

Créer le handler API tRPC

app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/trpc/root';
import { createNextApiHandler } from '@trpc/server/adapters/next';

const todos: { id: number; text: string; done: boolean }[] = [];

const handler = createNextApiHandler({
  router: appRouter,
  createContext: () => ({ todos }),
});

export { handler as GET, handler as POST };

Ce handler App Router expose tRPC via GET/POST. Même store in-memory partagé. createNextApiHandler gère le batching et CORS auto. Testez avec curl http://localhost:3000/api/trpc/todo.all. Piège : synchroniser le store si multi-instances (utilisez Redis en prod).

Configuration client tRPC + React Query

Côté client, createTRPCReact génère hooks type-safe. Wrappez avec QueryClient et TRPCProvider dans layout.tsx. Les types AppRouter sont inférés depuis le serveur.

Hooks et Provider tRPC client

src/trpc/react.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './root';
import superjson from 'superjson';

export const api = createTRPCReact<AppRouter, any, null>();

export function TRPCReactProvider(props: { children: React.ReactNode }) {
  const queryClient = React.useState(() => new QueryClient())[0];
  const [trpcClient] = React.useState(() =>
    api.createClient({
      transformer: superjson,
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc',
        }),
      ],
    }),
  );
  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
}

Ce module crée api pour hooks comme api.todo.all.useQuery(). httpBatchLink batch les calls pour perf x10. Provider gère React Query global. En prod, ajoutez WebSocketLink pour subs. Astuce : localhost pour dev, env var pour prod.

Intégrer le Provider dans le layout

app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { TRPCReactProvider } from '@/trpc/react';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'tRPC Next.js Demo',
  description: 'App Todo avec tRPC type-safe',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="fr">
      <body className={inter.className}>
        <TRPCReactProvider>{children}</TRPCReactProvider>
      </body>
    </html>
  );
}

Le Provider wrappe toute l'app pour hooks globaux. Metadata SEO-ready. Relancez npm run dev : tRPC est prêt ! Sans ça, hooks échouent silencieusement.

Composant Todo utilisant tRPC

Maintenant, un composant full-stack : liste, add, toggle, delete. Hooks infèrent types, suspense pour loading, optimistic pour UX fluide.

Page principale avec todos CRUD

app/page.tsx
import { api } from '@/trpc/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Suspense } from 'react';

function TodoList() {
  const { data: todos, isLoading } = api.todo.all.useQuery();
  const utils = api.useUtils();
  const createMutation = api.todo.create.useMutation({
    onMutate: async (newTodo) => {
      await utils.todo.all.invalidate();
    },
  });
  const toggleMutation = api.todo.toggle.useMutation();
  const deleteMutation = api.todo.delete.useMutation();
  const [newText, setNewText] = React.useState('');
  if (isLoading) return <div>Chargement...</div>;
  return (
    <div className="p-8 max-w-md mx-auto">
      <h1 className="text-2xl font-bold mb-4">Mes Todos tRPC</h1>
      <div className="flex gap-2 mb-4">
        <Input
          value={newText}
          onChange={(e) => setNewText(e.target.value)}
          placeholder="Nouvelle todo"
        />
        <Button
          onClick={() => {
            createMutation.mutate({ text: newText });
            setNewText('');
          }}
        >
          Ajouter
        </Button>
      </div>
      <ul>
        {todos?.todos.map((todo) => (
          <li key={todo.id} className="flex gap-2 items-center mb-2 p-2 border rounded">
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => toggleMutation.mutate({ id: todo.id })}
            />
            <span className={todo.done ? 'line-through' : ''}>{todo.text}</span>
            <Button
              variant="destructive"
              size="sm"
              onClick={() => deleteMutation.mutate({ id: todo.id })}
            >
              Supprimer
            </Button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default function Page() {
  return (
    <Suspense fallback="Chargement des todos...">
      <TodoList />
    </Suspense>
  );
}

Hooks comme useQuery/useMutation sont fully typed. invalidate() refresh les queries liées. Suspense gère loading global. Ajoutez Shadcn/UI pour Button/Input (npx shadcn-ui@latest init). Testez : app todo live ! Optimistic updates via onMutate pour UX native.

Bonnes pratiques

  • Séparez routers par feature : userRouter, postRouter pour scalabilité.
  • Utilisez middlewares : t.middleware(({ next }) => next({ ctx: { user: getUser() } })) pour auth.
  • Activez batching : auto avec httpBatchLink, réduit les roundtrips.
  • Migrations DB : couplez avec Prisma + tRPC generator pour types auto.
  • Observabilité : intégrez Sentry pour TRPCError tracking.

Erreurs courantes à éviter

  • Store non partagé : context différent handler/dev → données perdues ; utilisez singleton ou DB.
  • Oublier transformer : sans superjson, Dates/BigInt cassent.
  • Pas de Zod : inputs non validés → sécurité zéro ; toujours .input(z.schema()).
  • Provider manquant : hooks undefined ; wrappez dans layout.

Pour aller plus loin