Skip to main content
All articles
6 min read

Building Multi-Tenant SaaS Platforms with Next.js and Prisma

Hard-won lessons from shipping three separate web apps in 16 months — schema design, row-level isolation, and keeping Stripe subscriptions sane across tenants.

Next.js
Prisma
SaaS
PostgreSQL

After leading the development of three SaaS products at Seniornicity in just 16 months, I picked up a lot of opinions on how to structure a multi-tenant Next.js app. Here's what actually worked.

Context

All three products shared the same Postgres database and a single Prisma schema. Tenants were healthcare providers — clinics, home care agencies, and staffing firms — each with strict data boundaries.

Schema design: tenant isolation from day one

The biggest mistake I see teams make is treating multi-tenancy as an afterthought. We used Prisma with a shared-database, shared-schema approach — every table has a tenantId column, and every query is scoped to it. It's the simplest model to start with and scales surprisingly far before you need row-level security at the DB layer.

model Provider {
  id       String @id @default(cuid())
  tenantId String
  name     String
  tenant   Tenant @relation(fields: [tenantId], references: [id])
}

We enforced scoping in a thin repository layer rather than in individual route handlers. This made auditing easy and prevented the "I forgot the WHERE clause" bug class entirely.

Watch out

Don't skip the index on tenantId. Every query uses it. Without an index, everything feels fine in development and gets painful in production once a table grows past ~50k rows.

Next.js middleware for subdomain routing

Each tenant got their own subdomain (tenant.seniornicity.com). Next.js middleware runs at the edge and rewrites the request to a tenant-aware path before the page component ever sees it:

// middleware.ts
export function middleware(req: NextRequest) {
  const host = req.headers.get('host') ?? ''
  const subdomain = host.split('.')[0]
  if (subdomain !== 'www' && subdomain !== 'app') {
    return NextResponse.rewrite(
      new URL(`/tenant/${subdomain}${req.nextUrl.pathname}`, req.url)
    )
  }
}

This kept the page tree clean — no tenant slug leaking into every URL segment.

Stripe: one subscription model for multiple plans

We modelled tenants as Stripe Customers and their plan tiers as Stripe Products with price IDs stored in our DB. On webhook receipt, we update the tenant's planStatus and currentPeriodEnd fields.

Always derive access from your own DB, never from Stripe's API at runtime. Cache the state locally and reconcile via webhooks.

What I'd do differently

  • Add a tenantId index on every table from the start — we paid the migration cost later.
  • Use Prisma's $extends client extensions to auto-inject tenantId rather than passing it manually everywhere.
  • Set up Stripe webhook idempotency keys from day one — replaying events in development will thank you.
Key takeaway

Multi-tenancy is mostly just discipline. Enforce the boundary at the data layer — not the UI layer — and you'll avoid 90% of the bugs.