Skip to main content
All articles
3 min read

Next.js 15 + tRPC + Prisma: The Winning Trio for Your SaaS

Step-by-step setup of the most productive technical trio for a SaaS in 2026: App Router, end-to-end type safety, and a modern ORM.

The problem: REST is dead (for SaaS)

When you build a SaaS, you spend a huge amount of time keeping your API and your frontend in sync. Duplicated types, duplicated validation, documentation that drifts out of date... it's all wasted time.

tRPC removes this problem by sharing types between server and client automatically.

Setup: from zero to productive

1. Project structure

src/
  server/
    api/
      root.ts          ← Router registry
      trpc.ts          ← Procedures and middlewares
      routers/
        invoice.ts     ← Invoicing router
        client.ts      ← Clients router
        ...
  app/
    dashboard/
      facturation/
        page.tsx       ← React page (Server Component)

2. Define the procedures

Procedures are the heart of tRPC. They replace REST endpoints:

// src/server/api/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
 
const t = initTRPC.context<Context>().create();
 
// Public procedure: accessible without auth
export const publicProcedure = t.procedure;
 
// Protected procedure: authentication required
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({ ctx: { session: ctx.session } });
});
 
// Staff procedure: role ≠ CLIENT
export const staffProcedure = protectedProcedure.use(({ ctx, next }) => {
  if (ctx.session.user.role === "CLIENT") {
    throw new TRPCError({ code: "FORBIDDEN" });
  }
  return next({ ctx });
});

3. Build a 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-filters by organizationId
      return ctx.orgDb.invoice.create({
        data: {
          clientId: input.clientId,
          lines: { create: input.lines },
          authorId: ctx.session.user.id,
        },
      });
    }),
});

4. Call it from the 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>
  );
}

Notice: zero type annotations on the client side. TypeScript infers everything from the router.

Prisma: the ORM that changes everything

Multi-tenant auto-scoping

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

Migrations

# Add a field
npx prisma migrate dev --name add-invoice-due-date
 
# Regenerate the typed client
npx prisma generate

Granular RBAC pattern

// Permission-based access control
import { requirePermission } from "~/server/api/trpc";
 
export const invoiceRouter = createTRPCRouter({
  // Only users with "facturation:create" can create one
  create: requirePermission("facturation:create")
    .input(createInvoiceSchema)
    .mutation(async ({ ctx, input }) => {
      // ...
    }),
 
  // Reading: "facturation:read" permission
  getAll: requirePermission("facturation:read").query(async ({ ctx }) => {
    // ...
  }),
});

Conclusion

This trio (Next.js 15 + tRPC + Prisma) gives you:

  • Type safety from the database all the way to the React component
  • Zero boilerplate: no duplicated types
  • Great DX: autocomplete everywhere
  • Performance: Server Components + optimized queries

Up next: implementing multi-tenant authentication with NextAuth v5.

Go further

Share

Ready to launch your SaaS?

HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.

30-day money-back guarantee