Créer des APIs Type-Safe de bout en bout avec tRPC et Next.js App Router

La sécurité de type de bout en bout sans génération de code. tRPC vous permet d'appeler des fonctions serveur depuis le client avec autocomplétion TypeScript complète, validation et gestion d'erreurs — pas de schémas REST, pas de résolveurs GraphQL, pas de spécifications OpenAPI. Dans ce tutoriel, vous allez construire un gestionnaire de tâches complet avec Next.js 15 App Router et tRPC.
Ce que vous allez apprendre
À la fin de ce tutoriel, vous saurez :
- Configurer tRPC v11 avec Next.js 15 App Router en utilisant l'adaptateur fetch
- Définir des requêtes, mutations et abonnements avec validation Zod
- Créer des middlewares pour l'authentification et la journalisation
- Intégrer TanStack React Query v5 pour la récupération de données côté client
- Utiliser des appelants côté serveur dans les Server Components
- Gérer les erreurs avec le système d'erreurs intégré de tRPC
- Construire un gestionnaire de tâches fonctionnel avec des opérations CRUD complètes
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé (
node --version) - De l'expérience en TypeScript (types, génériques, inférence)
- Une familiarité avec Next.js App Router (Server Components, gestionnaires de routes)
- Les bases de React Query (optionnel, nous couvrirons ce dont vous avez besoin)
- Un éditeur de code — VS Code ou Cursor recommandé
Pourquoi tRPC ?
Si votre frontend et votre backend sont tous deux en TypeScript, vous partagez déjà un système de types. Alors pourquoi écrire des endpoints REST avec des types requête/réponse séparés, ou maintenir un schéma GraphQL ? tRPC élimine entièrement cette duplication.
| Fonctionnalité | REST | GraphQL | tRPC |
|---|---|---|---|
| Sécurité de type | Manuelle | Génération de code | Automatique |
| Définition de schéma | OpenAPI | SDL | Aucune nécessaire |
| Courbe d'apprentissage | Faible | Moyenne | Faible |
| Taille du bundle | Variable | Lourd | Minimal |
| Idéal pour | APIs publiques | Graphes complexes | Apps TS vers TS |
tRPC excelle quand votre client et serveur partagent le même codebase TypeScript — ce qui est exactement ce que Next.js vous offre.
Étape 1 : Créer un projet Next.js
Commencez par échafauder un nouveau projet Next.js 15 :
npx create-next-app@latest trpc-task-manager --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd trpc-task-managerÉtape 2 : Installer tRPC et les dépendances
Installez les packages tRPC ainsi que TanStack React Query et Zod :
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zodVoici ce que fait chaque package :
@trpc/server— définit votre routeur API, procédures et middleware@trpc/client— client TypeScript vanilla pour appeler votre API@trpc/react-query— hooks React encapsulant TanStack React Query@tanstack/react-query— gestion puissante de l'état asynchrone pour Reactzod— validation de schéma à l'exécution et inférence de types TypeScript
Étape 3 : Initialiser le backend tRPC
Créez le fichier d'initialisation tRPC. C'est ici que vous définissez votre contexte et vos constructeurs de procédures.
Créez src/trpc/init.ts :
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
// Define the context available to all procedures
export type Context = {
userId: string | null;
};
// Create context for each request
export const createTRPCContext = async (): Promise<Context> => {
// In a real app, extract user from session/JWT here
return {
userId: null,
};
};
// Initialize tRPC — this should only be done once
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
// Export reusable helpers
export const router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
// Middleware: require authentication
const enforceAuth = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
return next({
ctx: {
userId: ctx.userId,
},
});
});
export const protectedProcedure = t.procedure.use(enforceAuth);Ce fichier configure trois éléments importants :
- Le contexte — les données disponibles pour chaque procédure (comme l'utilisateur courant)
- Les procédures publiques — accessibles sans authentification
- Les procédures protégées — nécessitent un utilisateur connecté
Étape 4 : Définir le routeur des tâches
Maintenant, créez la logique API proprement dite. Créez src/trpc/routers/task.ts :
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../init";
import { TRPCError } from "@trpc/server";
// In-memory store (replace with a real database in production)
interface Task {
id: string;
title: string;
description: string;
completed: boolean;
createdAt: Date;
updatedAt: Date;
}
const tasks: Task[] = [
{
id: "1",
title: "Learn tRPC",
description: "Build a type-safe API with tRPC and Next.js",
completed: false,
createdAt: new Date(),
updatedAt: new Date(),
},
];
// Input validation schemas
const createTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(100),
description: z.string().max(500).default(""),
});
const updateTaskSchema = z.object({
id: z.string(),
title: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
completed: z.boolean().optional(),
});
export const taskRouter = router({
// GET all tasks
list: publicProcedure
.input(
z
.object({
filter: z.enum(["all", "active", "completed"]).default("all"),
})
.optional()
)
.query(({ input }) => {
const filter = input?.filter ?? "all";
if (filter === "active") return tasks.filter((t) => !t.completed);
if (filter === "completed") return tasks.filter((t) => t.completed);
return tasks;
}),
// GET single task by ID
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const task = tasks.find((t) => t.id === input.id);
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Task with ID ${input.id} not found`,
});
}
return task;
}),
// CREATE a new task
create: publicProcedure
.input(createTaskSchema)
.mutation(({ input }) => {
const newTask: Task = {
id: crypto.randomUUID(),
title: input.title,
description: input.description,
completed: false,
createdAt: new Date(),
updatedAt: new Date(),
};
tasks.push(newTask);
return newTask;
}),
// UPDATE an existing task
update: publicProcedure
.input(updateTaskSchema)
.mutation(({ input }) => {
const index = tasks.findIndex((t) => t.id === input.id);
if (index === -1) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Task with ID ${input.id} not found`,
});
}
const updated = {
...tasks[index],
...input,
updatedAt: new Date(),
};
tasks[index] = updated;
return updated;
}),
// DELETE a task
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const index = tasks.findIndex((t) => t.id === input.id);
if (index === -1) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Task with ID ${input.id} not found`,
});
}
const deleted = tasks.splice(index, 1)[0];
return deleted;
}),
});Remarquez comment chaque entrée est validée avec Zod. Si un client envoie des données invalides, tRPC retourne automatiquement une erreur 400 avec des messages de validation détaillés — et TypeScript détecte l'incompatibilité au moment de la compilation.
Étape 5 : Créer le routeur principal
Combinez tous vos routeurs en un seul routeur racine. Créez src/trpc/routers/_app.ts :
import { router } from "../init";
import { taskRouter } from "./task";
export const appRouter = router({
task: taskRouter,
});
// Export the type — this is the magic that enables end-to-end type safety
export type AppRouter = typeof appRouter;Le type AppRouter est la clé. Vous exportez ce type et l'importez côté client — aucun code d'exécution ne traverse la frontière, uniquement les types. TypeScript infère chaque entrée, sortie et erreur de vos procédures.
Étape 6 : Configurer le gestionnaire de routes
Connectez tRPC à Next.js en utilisant l'adaptateur fetch. Créez src/app/api/trpc/[trpc]/route.ts :
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { createTRPCContext } from "@/trpc/init";
import { appRouter } from "@/trpc/routers/_app";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };Cela crée une route attrape-tout à /api/trpc/*. Chaque procédure tRPC devient automatiquement un endpoint — task.list correspond à /api/trpc/task.list.
Étape 7 : Créer le client tRPC
Maintenant, configurez l'intégration côté client. Vous avez besoin de deux fichiers.
D'abord, créez les hooks tRPC React dans src/trpc/client.ts :
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "./routers/_app";
export const trpc = createTRPCReact<AppRouter>();Puis créez le provider dans src/trpc/provider.tsx :
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { useState } from "react";
import { trpc } from "./client";
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 * 1000,
retry: 1,
},
},
}));
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Ajoutez le provider à votre layout racine dans src/app/layout.tsx :
import { TRPCProvider } from "@/trpc/provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Étape 8 : Construire l'interface du gestionnaire de tâches
Maintenant la partie amusante — utiliser tRPC dans vos composants avec une sécurité de type complète.
Créez src/app/page.tsx :
"use client";
import { useState } from "react";
import { trpc } from "@/trpc/client";
export default function TaskManager() {
const [filter, setFilter] = useState<"all" | "active" | "completed">("all");
const [newTitle, setNewTitle] = useState("");
const [newDescription, setNewDescription] = useState("");
// Queries — fully typed, no manual type annotations needed
const tasksQuery = trpc.task.list.useQuery({ filter });
// Mutations with automatic cache invalidation
const utils = trpc.useUtils();
const createTask = trpc.task.create.useMutation({
onSuccess: () => {
utils.task.list.invalidate();
setNewTitle("");
setNewDescription("");
},
});
const updateTask = trpc.task.update.useMutation({
onSuccess: () => utils.task.list.invalidate(),
});
const deleteTask = trpc.task.delete.useMutation({
onSuccess: () => utils.task.list.invalidate(),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
createTask.mutate({
title: newTitle,
description: newDescription,
});
};
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Task Manager</h1>
{/* Create Task Form */}
<form onSubmit={handleSubmit} className="mb-8 space-y-4">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Task title..."
className="w-full p-3 border rounded-lg"
/>
<textarea
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="Description (optional)"
className="w-full p-3 border rounded-lg"
rows={2}
/>
<button
type="submit"
disabled={createTask.isPending}
className="px-6 py-3 bg-blue-600 text-white rounded-lg
hover:bg-blue-700 disabled:opacity-50"
>
{createTask.isPending ? "Adding..." : "Add Task"}
</button>
</form>
{/* Filter Tabs */}
<div className="flex gap-2 mb-6">
{(["all", "active", "completed"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg capitalize ${
filter === f
? "bg-blue-600 text-white"
: "bg-gray-100 hover:bg-gray-200"
}`}
>
{f}
</button>
))}
</div>
{/* Task List */}
{tasksQuery.isLoading && <p>Loading tasks...</p>}
{tasksQuery.error && (
<p className="text-red-600">Error: {tasksQuery.error.message}</p>
)}
<ul className="space-y-3">
{tasksQuery.data?.map((task) => (
<li
key={task.id}
className="flex items-center gap-4 p-4 border rounded-lg"
>
<input
type="checkbox"
checked={task.completed}
onChange={() =>
updateTask.mutate({
id: task.id,
completed: !task.completed,
})
}
className="w-5 h-5"
/>
<div className="flex-1">
<h3
className={`font-medium ${
task.completed ? "line-through text-gray-400" : ""
}`}
>
{task.title}
</h3>
{task.description && (
<p className="text-sm text-gray-500">{task.description}</p>
)}
</div>
<button
onClick={() => deleteTask.mutate({ id: task.id })}
className="text-red-500 hover:text-red-700"
>
Delete
</button>
</li>
))}
</ul>
{tasksQuery.data?.length === 0 && (
<p className="text-center text-gray-500 py-8">
No tasks found. Create one above.
</p>
)}
</main>
);
}Remarquez l'autocomplétion. Quand vous tapez trpc.task., votre éditeur affiche list, byId, create, update, delete. Quand vous tapez createTask.mutate({, vous obtenez l'autocomplétion pour title et description avec leurs types exacts. C'est la magie de tRPC — zéro définition de types manuelle côté client.
Étape 9 : Appels côté serveur dans les Server Components
L'une des meilleures fonctionnalités de tRPC est d'appeler des procédures directement depuis les Server Components sans surcharge HTTP.
Créez src/trpc/server.ts :
import { createCallerFactory } from "./init";
import { appRouter } from "./routers/_app";
const createCaller = createCallerFactory(appRouter);
export const serverTRPC = createCaller({
userId: null, // populate from session in real apps
});Maintenant utilisez-le dans un Server Component. Créez src/app/stats/page.tsx :
import { serverTRPC } from "@/trpc/server";
export default async function StatsPage() {
// Direct function call — no HTTP, no serialization overhead
const allTasks = await serverTRPC.task.list({ filter: "all" });
const completedTasks = await serverTRPC.task.list({ filter: "completed" });
const completionRate =
allTasks.length > 0
? Math.round((completedTasks.length / allTasks.length) * 100)
: 0;
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Task Statistics</h1>
<div className="grid grid-cols-3 gap-4">
<div className="p-6 bg-blue-50 rounded-lg text-center">
<p className="text-3xl font-bold text-blue-600">{allTasks.length}</p>
<p className="text-gray-600">Total Tasks</p>
</div>
<div className="p-6 bg-green-50 rounded-lg text-center">
<p className="text-3xl font-bold text-green-600">
{completedTasks.length}
</p>
<p className="text-gray-600">Completed</p>
</div>
<div className="p-6 bg-purple-50 rounded-lg text-center">
<p className="text-3xl font-bold text-purple-600">
{completionRate}%
</p>
<p className="text-gray-600">Completion Rate</p>
</div>
</div>
</main>
);
}L'appel serverTRPC.task.list() s'exécute directement sur le serveur — même processus, même mémoire, aucun appel réseau. TypeScript applique toujours le contrat complet.
Étape 10 : Ajouter un middleware de journalisation
Le middleware tRPC vous permet d'ajouter des préoccupations transversales comme la journalisation, la limitation de débit ou les analytics.
Mettez à jour src/trpc/init.ts pour ajouter un middleware de journalisation :
// Add this after the existing code in init.ts
const logger = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (result.ok) {
console.log(`[tRPC] ${type} ${path} — ${duration}ms OK`);
} else {
console.error(`[tRPC] ${type} ${path} — ${duration}ms ERROR`);
}
return result;
});
export const loggedProcedure = t.procedure.use(logger);Vous pouvez empiler les middlewares. Une procédure peut utiliser à la fois logger et enforceAuth :
export const loggedProtectedProcedure = t.procedure
.use(logger)
.use(enforceAuth);Étape 11 : Bonnes pratiques de gestion des erreurs
tRPC fournit une gestion structurée des erreurs par défaut. Voici comment l'utiliser efficacement.
Lever des erreurs dans les procédures
import { TRPCError } from "@trpc/server";
// In your procedure
throw new TRPCError({
code: "BAD_REQUEST",
message: "Title cannot be empty",
cause: originalError, // optional — for debugging
});Codes d'erreur disponibles
| Code | Statut HTTP | Quand utiliser |
|---|---|---|
BAD_REQUEST | 400 | Entrée invalide |
UNAUTHORIZED | 401 | Non connecté |
FORBIDDEN | 403 | Pas de permission |
NOT_FOUND | 404 | Ressource manquante |
CONFLICT | 409 | Entrée dupliquée |
TOO_MANY_REQUESTS | 429 | Limitation de débit |
INTERNAL_SERVER_ERROR | 500 | Erreur inattendue |
Gérer les erreurs côté client
const createTask = trpc.task.create.useMutation({
onError: (error) => {
// Zod validation errors
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
console.log("Validation errors:", fieldErrors);
return;
}
// tRPC errors
console.log("Error code:", error.data?.code);
console.log("Message:", error.message);
},
});Étape 12 : Mises à jour optimistes
Pour une interface réactive, vous pouvez mettre à jour le cache avant que le serveur ne réponde :
const updateTask = trpc.task.update.useMutation({
onMutate: async (newData) => {
// Cancel outgoing refetches
await utils.task.list.cancel();
// Snapshot current data
const previous = utils.task.list.getData({ filter: "all" });
// Optimistically update
utils.task.list.setData({ filter: "all" }, (old) =>
old?.map((task) =>
task.id === newData.id ? { ...task, ...newData } : task
)
);
return { previous };
},
onError: (_err, _newData, context) => {
// Rollback on error
if (context?.previous) {
utils.task.list.setData({ filter: "all" }, context.previous);
}
},
onSettled: () => {
utils.task.list.invalidate();
},
});Tester votre implémentation
Lancez le serveur de développement :
npm run devOuvrez http://localhost:3000 et vérifiez :
- La liste des tâches se charge avec la tâche de départ
- Vous pouvez créer de nouvelles tâches avec le formulaire
- Cliquer sur la case à cocher bascule l'état de complétion
- Le bouton supprimer retire les tâches
- Les onglets de filtre fonctionnent correctement
- Visitez
/statspour voir les statistiques rendues côté serveur
Tester avec curl
Vous pouvez aussi tester l'API directement :
# List all tasks
curl "http://localhost:3000/api/trpc/task.list?input=%7B%7D"
# Create a task
curl -X POST "http://localhost:3000/api/trpc/task.create" \
-H "Content-Type: application/json" \
-d '{"json":{"title":"Test from curl","description":"Works!"}}'Structure du projet
Voici la structure finale du projet :
src/
├── app/
│ ├── api/trpc/[trpc]/
│ │ └── route.ts # Gestionnaire de route tRPC
│ ├── stats/
│ │ └── page.tsx # Server Component avec appels côté serveur
│ ├── layout.tsx # Layout racine avec TRPCProvider
│ └── page.tsx # Interface du gestionnaire de tâches
└── trpc/
├── client.ts # Hooks React (createTRPCReact)
├── init.ts # Initialisation tRPC, contexte, middleware
├── provider.tsx # Provider côté client
├── server.ts # Appelant côté serveur
└── routers/
├── _app.ts # Routeur racine
└── task.ts # Procédures des tâches
Dépannage
Erreurs "Cannot find module"
Assurez-vous que votre tsconfig.json a les alias de chemin configurés :
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}Avertissements "Hydration mismatch"
Assurez-vous que votre TRPCProvider est marqué avec "use client" et n'encapsule que les parties de votre application qui nécessitent les hooks tRPC côté client.
Données obsolètes après les mutations
Appelez toujours utils.task.list.invalidate() dans onSuccess ou onSettled pour récupérer les données après une mutation.
Erreurs de type après modification des procédures
Si vous modifiez l'entrée/sortie d'une procédure, TypeScript peut mettre en cache des types obsolètes. Redémarrez votre serveur TypeScript (Cmd+Shift+P → "TypeScript: Restart TS Server" dans VS Code).
Prochaines étapes
Maintenant que vous avez une configuration tRPC fonctionnelle, envisagez ces améliorations :
- Ajouter une vraie base de données — remplacez le stockage en mémoire par Drizzle ORM et PostgreSQL
- Ajouter l'authentification — intégrez AuthJS v5 et renseignez
ctx.userId - Ajouter des mises à jour en temps réel — utilisez les abonnements tRPC avec WebSockets
- Ajouter des tests — utilisez
createCallerFactorypour les tests unitaires des procédures - Déployer — conteneurisez avec Docker ou déployez sur Vercel
Conclusion
tRPC change fondamentalement la façon dont vous construisez des APIs dans les applications TypeScript. Au lieu de maintenir des définitions de types séparées pour votre client et votre serveur, vous écrivez vos procédures une seule fois et laissez TypeScript tout inférer. Le résultat est :
- Moins de bugs — les incompatibilités de types sont détectées à la compilation, pas en production
- Développement plus rapide — autocomplétion pour chaque appel API, pas de définitions de types manuelles
- Moins de code — pas de boilerplate REST, pas de résolveurs GraphQL, pas d'étape de génération de code
- Meilleure DX — renommez un champ sur le serveur et TypeScript signale immédiatement chaque utilisation côté client
Combiné avec Next.js App Router, vous obtenez le meilleur des deux mondes : le rendu côté serveur avec des appels serveur sans surcharge, et l'interactivité côté client avec une sécurité de type complète. C'est la stack qui rend le développement full-stack TypeScript véritablement fluide.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire une Application Full-Stack avec Drizzle ORM et Next.js 15 : Base de Donnees Type-Safe du Zero a la Production
Apprenez a construire une application full-stack type-safe avec Drizzle ORM et Next.js 15. Ce tutoriel pratique couvre la conception de schemas, les migrations, les Server Actions, les operations CRUD et le deploiement avec PostgreSQL.

Construire un Agent IA Autonome avec Agentic RAG et Next.js
Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.

Créer et déployer une API serverless avec Cloudflare Workers, Hono et D1
Apprenez à construire une API REST prête pour la production avec Cloudflare Workers, le framework Hono et la base de données D1 — de la configuration initiale au déploiement mondial.