Aller au contenu principal
Tous les articles
·3 min de lecture

Next.js 15 + tRPC + Prisma : le trio gagnant pour votre SaaS

Setup pas à pas du trio technique le plus productif pour un SaaS en 2026 : App Router, type safety end-to-end, et ORM moderne.

nextjstrpcprismatutorial

Le problème : REST est mort (pour les SaaS)

Quand vous construisez un SaaS, vous passez un temps fou à maintenir la synchronisation entre votre API et votre frontend. Types dupliqués, validation dupliquée, documentation à jour... C'est du temps perdu.

tRPC élimine ce problème en partageant les types entre serveur et client automatiquement.

Setup : de zéro à productif

1. Structure du projet

src/
  server/
    api/
      root.ts          ← Registre des routers
      trpc.ts          ← Procédures et middlewares
      routers/
        invoice.ts     ← Router facturation
        client.ts      ← Router clients
        ...
  app/
    dashboard/
      facturation/
        page.tsx       ← Page React (Server Component)

2. Définir les procédures

Les procédures sont le cœur de tRPC. Elles remplacent les endpoints REST :

// src/server/api/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
 
const t = initTRPC.context<Context>().create();
 
// Procédure publique — accessible sans auth
export const publicProcedure = t.procedure;
 
// Procédure protégée — authentification requise
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({ ctx: { session: ctx.session } });
});
 
// Procédure staff — rôle ≠ CLIENT
export const staffProcedure = protectedProcedure.use(({ ctx, next }) => {
  if (ctx.session.user.role === "CLIENT") {
    throw new TRPCError({ code: "FORBIDDEN" });
  }
  return next({ ctx });
});

3. Créer un router

// src/server/api/routers/invoice.ts
import { z } from "zod";
import { createTRPCRouter, staffProcedure } from "~/server/api/trpc";
 
export const invoiceRouter = createTRPCRouter({
  getAll: staffProcedure
    .input(
      z.object({
        status: z.enum(["DRAFT", "SENT", "PAID"]).optional(),
      }),
    )
    .query(async ({ ctx, input }) => {
      return ctx.orgDb.invoice.findMany({
        where: input.status ? { status: input.status } : undefined,
        orderBy: { createdAt: "desc" },
        include: { client: { select: { name: true } } },
      });
    }),
 
  create: staffProcedure
    .input(
      z.object({
        clientId: z.string(),
        lines: z.array(
          z.object({
            description: z.string(),
            quantity: z.number().positive(),
            unitPrice: z.number().positive(),
          }),
        ),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      // ctx.orgDb auto-filtre par organizationId
      return ctx.orgDb.invoice.create({
        data: {
          clientId: input.clientId,
          lines: { create: input.lines },
          authorId: ctx.session.user.id,
        },
      });
    }),
});

4. Appeler depuis le frontend

"use client";
 
import { api } from "~/trpc/react";
 
export function InvoiceList() {
  const { data, isLoading } = api.invoice.getAll.useQuery({
    status: "DRAFT",
  });
 
  if (isLoading) return <Skeleton />;
 
  return (
    <ul>
      {data?.map((invoice) => (
        <li key={invoice.id}>
          {invoice.client.name}{invoice.totalHT}
        </li>
      ))}
    </ul>
  );
}

Remarquez : zéro type annoté côté client. TypeScript infère tout depuis le router.

Prisma : l'ORM qui change tout

Auto-scope multi-tenant

// ctx.orgDb filtre automatiquement par organizationId
const clients = await ctx.orgDb.client.findMany();
// SQL: SELECT * FROM "Client" WHERE "organizationId" = 'org_xxx'

Migrations

# Ajouter un champ
npx prisma migrate dev --name add-invoice-due-date
 
# Régénérer le client typé
npx prisma generate

Pattern RBAC granulaire

// Permission-based access control
import { requirePermission } from "~/server/api/trpc";
 
export const invoiceRouter = createTRPCRouter({
  // Seuls les users avec "facturation:create" peuvent créer
  create: requirePermission("facturation:create")
    .input(createInvoiceSchema)
    .mutation(async ({ ctx, input }) => {
      // ...
    }),
 
  // Lecture : permission "facturation:read"
  getAll: requirePermission("facturation:read").query(async ({ ctx }) => {
    // ...
  }),
});

Conclusion

Ce trio (Next.js 15 + tRPC + Prisma) vous donne :

  • Type safety de la DB au composant React
  • Zero boilerplate — pas de types dupliqués
  • DX incroyable — autocomplétion partout
  • Performance — Server Components + requêtes optimisées

Dans le prochain article : comment implémenter l'authentification multi-tenant avec NextAuth v5.