Construire une API Production-Ready avec tRPC, Prisma et Next.js

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Construire une API Production-Ready avec tRPC, Prisma et Next.js

Construire des APIs modernes nécessite d'équilibrer l'expérience développeur, la sécurité des types et les performances. Les APIs REST traditionnelles manquent de sécurité des types end-to-end, tandis que GraphQL ajoute une complexité significative. tRPC offre une solution élégante : sécurité complète des types TypeScript sans génération de code, combinée avec Prisma pour la gestion de base de données et Next.js 15 pour le rendu serveur et client.

À la fin de ce tutoriel, vous aurez construit une API complète de gestion de tâches avec authentification, opérations de base de données et un client React—le tout avec une sécurité complète des types de la base de données à l'interface utilisateur.

Pourquoi tRPC + Prisma ?

tRPC (TypeScript Remote Procedure Call) élimine le problème du contrat API. Lorsque vous modifiez votre backend, vos types frontend se mettent à jour automatiquement. Pas de documentation API manuelle, pas de schémas OpenAPI, pas d'étapes de génération de code.

Prisma fournit un ORM type-safe avec migrations, introspection et une excellente DX. Combiné avec tRPC, vous obtenez la sécurité des types du schéma de base de données aux composants React.

Avantages réels :

  • Détecter les erreurs à la compilation, pas à l'exécution
  • Refactoriser en toute confiance (renommez un champ, TypeScript trouve toutes les utilisations)
  • Autocomplétion partout
  • Moins de code boilerplate comparé à REST ou GraphQL

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 18+ (vérifier : `node -v`)
  • npm, yarn ou pnpm (nous utiliserons pnpm)
  • PostgreSQL installé localement ou une base de données cloud
  • Connaissances de base en TypeScript et React
  • Familiarité avec les fondamentaux de Next.js

Installer PostgreSQL :

# macOS
brew install postgresql@15
brew services start postgresql@15
 
# Ubuntu/Debian
sudo apt update && sudo apt install postgresql postgresql-contrib
sudo systemctl start postgresql

Étape 1 : Configuration du Projet

Créer un nouveau projet Next.js :

pnpm create next-app@latest trpc-task-api --typescript --tailwind --app --eslint
cd trpc-task-api

Installer les dépendances :

pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query@^5
pnpm add @prisma/client zod superjson
pnpm add -D prisma

Initialiser Prisma :

pnpx prisma init

Étape 2 : Schéma de Base de Données

Modifier `prisma/schema.prisma` :

generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  tasks     Task[]
}
 
model Task {
  id          String     @id @default(cuid())
  title       String
  description String?
  status      TaskStatus @default(TODO)
  priority    Priority   @default(MEDIUM)
  userId      String
  user        User       @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
  @@index([userId])
}
 
enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}
 
enum Priority {
  LOW
  MEDIUM
  HIGH
  URGENT
}

Mettre à jour `.env` :

DATABASE_URL="postgresql://postgres:password@localhost:5432/trpc_tasks"

Exécuter la migration :

pnpx prisma migrate dev --name init

Étape 3 : Client Prisma

Créer `lib/prisma.ts` :

import { PrismaClient } from '@prisma/client'
 
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }
 
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
})
 
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Pourquoi ce pattern ? Next.js hot-reload crée plusieurs instances Prisma en développement. Ce pattern singleton évite l'erreur "trop de connexions".

Étape 4 : Configuration tRPC

Créer `lib/trpc/trpc.ts` :

import { initTRPC, TRPCError } from '@trpc/server'
import { prisma } from '@/lib/prisma'
import superjson from 'superjson'
 
export const createTRPCContext = async (opts: { headers: Headers }) => {
  return {
    prisma,
    userId: opts.headers.get('x-user-id') || undefined,
  }
}
 
const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
})
 
export const router = t.router
export const publicProcedure = t.procedure
 
export const protectedProcedure = t.procedure.use(async (opts) => {
  if (!opts.ctx.userId) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }
  return opts.next({ ctx: { ...opts.ctx, userId: opts.ctx.userId } })
})

Concepts clés :

  • Context – Données disponibles pour toutes les procédures (base de données, session utilisateur, etc.)
  • Transformer – `superjson` permet les objets Date et autres types non-JSON
  • Procedures – Comme des fonctions exposées via l'API
  • Middleware – `protectedProcedure` vérifie l'authentification

Étape 5 : Router de Tâches

Créer `lib/trpc/routers/task.ts` :

import { z } from 'zod'
import { router, protectedProcedure } from '../trpc'
import { TaskStatus, Priority } from '@prisma/client'
import { TRPCError } from '@trpc/server'
 
export const taskRouter = router({
  list: protectedProcedure
    .input(z.object({ 
      status: z.nativeEnum(TaskStatus).optional(),
      limit: z.number().min(1).max(100).default(50) 
    }))
    .query(async ({ ctx, input }) => {
      return await ctx.prisma.task.findMany({
        where: { 
          userId: ctx.userId,
          ...(input.status && { status: input.status })
        },
        orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
        take: input.limit,
      })
    }),
 
  getById: protectedProcedure
    .input(z.object({ id: z.string().cuid() }))
    .query(async ({ ctx, input }) => {
      const task = await ctx.prisma.task.findUnique({ where: { id: input.id } })
      if (!task || task.userId !== ctx.userId) {
        throw new TRPCError({ code: 'NOT_FOUND' })
      }
      return task
    }),
 
  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      description: z.string().max(2000).optional(),
      priority: z.nativeEnum(Priority).default('MEDIUM'),
    }))
    .mutation(async ({ ctx, input }) => {
      return await ctx.prisma.task.create({
        data: { ...input, userId: ctx.userId },
      })
    }),
 
  update: protectedProcedure
    .input(z.object({
      id: z.string().cuid(),
      title: z.string().min(1).max(200).optional(),
      description: z.string().max(2000).optional().nullable(),
      status: z.nativeEnum(TaskStatus).optional(),
      priority: z.nativeEnum(Priority).optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input
      const existing = await ctx.prisma.task.findUnique({ where: { id } })
      if (!existing || existing.userId !== ctx.userId) {
        throw new TRPCError({ code: 'NOT_FOUND' })
      }
      return await ctx.prisma.task.update({ where: { id }, data })
    }),
 
  delete: protectedProcedure
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.prisma.task.findUnique({ where: { id: input.id } })
      if (!existing || existing.userId !== ctx.userId) {
        throw new TRPCError({ code: 'NOT_FOUND' })
      }
      await ctx.prisma.task.delete({ where: { id: input.id } })
      return { success: true }
    }),
 
  stats: protectedProcedure.query(async ({ ctx }) => {
    const [total, todo, inProgress, done] = await Promise.all([
      ctx.prisma.task.count({ where: { userId: ctx.userId } }),
      ctx.prisma.task.count({ where: { userId: ctx.userId, status: 'TODO' } }),
      ctx.prisma.task.count({ where: { userId: ctx.userId, status: 'IN_PROGRESS' } }),
      ctx.prisma.task.count({ where: { userId: ctx.userId, status: 'DONE' } }),
    ])
    return { total, todo, inProgress, done }
  }),
})

Structure du router :

  • Validation des entrées – Les schémas Zod empêchent les données invalides
  • Autorisation – Vérification de la propriété des tâches avant modifications
  • Inférence des types – TypeScript connaît les types exacts entrée/sortie

Étape 6 : Router Principal

Créer `lib/trpc/root.ts` :

import { router } from './trpc'
import { taskRouter } from './routers/task'
 
export const appRouter = router({
  task: taskRouter,
})
 
export type AppRouter = typeof appRouter

Étape 7 : Route API

Créer `app/api/trpc/[trpc]/route.ts` :

import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/lib/trpc/root'
import { createTRPCContext } from '@/lib/trpc/trpc'
 
const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: createTRPCContext,
  })
 
export { handler as GET, handler as POST }

Étape 8 : Configuration Client

Créer `lib/trpc/client.ts` :

import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from './root'
 
export const trpc = createTRPCReact<AppRouter>()

Créer `app/providers.tsx` :

'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { trpc } from '@/lib/trpc/client'
import { useState } from 'react'
import superjson from 'superjson'
 
export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: \`\${getBaseUrl()}/api/trpc\`,
          transformer: superjson,
          headers() {
            return { 'x-user-id': 'demo-user-123' }
          },
        }),
      ],
    })
  )
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  )
}
 
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}\`
}

Mettre à jour `app/layout.tsx` :

import { Providers } from './providers'
import './globals.css'
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="fr">
      <body><Providers>{children}</Providers></body>
    </html>
  )
}

Étape 9 : Composant d'Interface

Créer `app/tasks/page.tsx` :

'use client'
import { trpc } from '@/lib/trpc/client'
import { useState } from 'react'
 
export default function TasksPage() {
  const [title, setTitle] = useState('')
  const utils = trpc.useUtils()
  const { data: tasks, isLoading } = trpc.task.list.useQuery({ limit: 50 })
  const { data: stats } = trpc.task.stats.useQuery()
 
  const createTask = trpc.task.create.useMutation({
    onSuccess: () => {
      utils.task.list.invalidate()
      utils.task.stats.invalidate()
      setTitle('')
    },
  })
 
  const updateTask = trpc.task.update.useMutation({
    onSuccess: () => {
      utils.task.list.invalidate()
      utils.task.stats.invalidate()
    },
  })
 
  const deleteTask = trpc.task.delete.useMutation({
    onSuccess: () => {
      utils.task.list.invalidate()
      utils.task.stats.invalidate()
    },
  })
 
  if (isLoading) return <div className="p-8">Chargement...</div>
 
  return (
    <div className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">Gestionnaire de Tâches</h1>
 
      {stats && (
        <div className="grid grid-cols-4 gap-4 mb-8">
          <div className="p-4 border rounded"><div className="text-2xl font-bold">{stats.total}</div><div className="text-sm text-gray-600">Total</div></div>
          <div className="p-4 border rounded"><div className="text-2xl font-bold">{stats.todo}</div><div className="text-sm text-gray-600">À Faire</div></div>
          <div className="p-4 border rounded"><div className="text-2xl font-bold">{stats.inProgress}</div><div className="text-sm text-gray-600">En Cours</div></div>
          <div className="p-4 border rounded"><div className="text-2xl font-bold">{stats.done}</div><div className="text-sm text-gray-600">Terminé</div></div>
        </div>
      )}
 
      <form onSubmit={(e) => { e.preventDefault(); createTask.mutate({ title }) }} className="mb-8">
        <div className="flex gap-2">
          <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} 
            placeholder="Nouvelle tâche..." className="flex-1 px-4 py-2 border rounded" />
          <button type="submit" disabled={createTask.isPending} 
            className="px-6 py-2 bg-blue-600 text-white rounded">Ajouter</button>
        </div>
      </form>
 
      <div className="space-y-2">
        {tasks?.map((task) => (
          <div key={task.id} className="flex items-center gap-4 p-4 border rounded">
            <input type="checkbox" checked={task.status === 'DONE'} 
              onChange={() => updateTask.mutate({ id: task.id, status: task.status === 'DONE' ? 'TODO' : 'DONE' })} />
            <div className="flex-1">
              <h3 className={\`font-medium \${task.status === 'DONE' ? 'line-through' : ''}\`}>{task.title}</h3>
            </div>
            <span className="px-2 py-1 text-xs rounded bg-gray-100">{task.priority}</span>
            <button onClick={() => deleteTask.mutate({ id: task.id })} className="text-red-600">Supprimer</button>
          </div>
        ))}
      </div>
    </div>
  )
}

Étape 10 : Exécuter l'Application

pnpm dev

Visiter http://localhost:3000/tasks

Meilleures Pratiques Production

1. Authentification

Remplacer l'en-tête démo par NextAuth.js ou Clerk :

import { getServerSession } from 'next-auth'
 
export const createTRPCContext = async () => {
  const session = await getServerSession()
  return { prisma, userId: session?.user?.id }
}

2. Limitation de Débit

Utiliser Upstash ou similaire pour limiter les requêtes abusives.

3. Surveillance des Erreurs

Ajouter Sentry ou un outil similaire pour tracer les erreurs en production.

4. Pooling de Connexions

Pour serverless, utiliser Prisma Accelerate ou des poolers comme PgBouncer.

5. Mise en Cache

Implémenter le cache Redis pour les données fréquemment consultées.

Déploiement

Vercel

pnpx vercel

Définir `DATABASE_URL` dans les variables d'environnement.

Docker

FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install
COPY . .
RUN pnpx prisma generate
RUN pnpm build
EXPOSE 3000
CMD ["pnpm", "start"]

Résumé

Vous avez construit une API production-ready avec :

✅ Sécurité des types end-to-end de la base de données à l'interface ✅ Validation des entrées avec Zod ✅ Authentification et autorisation ✅ Migrations de base de données avec Prisma ✅ Cache et mises à jour optimistes avec React Query ✅ Batching des requêtes pour les performances

Points Clés

  1. tRPC élimine les contrats API – les types se synchronisent automatiquement
  2. Les types Prisma affluent vers tRPC – le schéma de base de données pilote les types API
  3. Les middlewares activent les patterns réutilisables – auth, logging, validation
  4. React Query alimente le client – cache, retries, mutations
  5. TypeScript détecte les erreurs tôt – sécurité à la compilation

Prochaines Étapes

  • Ajouter des abonnements WebSocket pour les mises à jour temps réel
  • Implémenter le téléchargement de fichiers
  • Ajouter un endpoint OpenAPI pour les intégrations tierces
  • Construire une app mobile avec React Native + tRPC
  • Implémenter des patterns CQRS avec bases séparées

Besoin d'Aide pour Construire des APIs Production ?

Noqta se spécialise dans la construction d'APIs robustes et type-safe et d'applications full-stack :

Réserver une consultation gratuite : Contacter Noqta


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Construire un monorepo de production avec Turborepo, Next.js et des packages partages.

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