Article

Multi-Tenant SaaS in Next.js: The Architecture Decisions You Cannot Undo Later

·6 min read min read·👁 0
Dharmendra Singh Yadav

Dharmendra Singh Yadav

AI Full-Stack Engineer

Architecture diagram showing tenant routing, isolation and caching layers in a multi-tenant Next.js application.

Most SaaS rewrites are not caused by choosing the wrong framework. They are caused by four decisions made in the first week, before anyone knew they were decisions.

Multi-tenancy is where those decisions live. Get them right and your product scales from one customer to a thousand with mostly routine work. Get them wrong and you will eventually face a migration that touches every query, every cache key, and every route in the application.

This post covers the four that matter, and what I would choose by default in a Next.js application.

Decision 1: Where Tenant Data Lives

There are three viable models, and the industry keeps rediscovering the same trade-offs.

Database per tenant

Each customer gets a physically separate database. Isolation is absolute, per-tenant backup and restore is trivial, and a runaway query from one customer cannot affect another.

The costs are real. Every schema migration becomes an orchestration job across N databases, with partial-failure states you must handle. Connection pooling becomes genuinely difficult, since most pooling strategies assume a bounded number of databases. Cross-tenant analytics turn into an ETL project. And provisioning a new customer is now infrastructure work rather than a row insert.

Choose this when: you have compliance requirements demanding physical separation, or a small number of large enterprise contracts where per-tenant operations justify the overhead.

Shared database, separate schemas

One database, one schema per tenant. This looks like a reasonable middle ground and occasionally is, but in practice it inherits most of the migration pain of database-per-tenant while providing weaker isolation. Postgres also starts to struggle once you have thousands of schemas, since catalog lookups degrade.

Choose this when: you are on Postgres, expect low hundreds of tenants, and want per-tenant logical separation without managing separate database instances. It is a narrower window than most people assume.

Shared database, shared schema, tenant column

Every row carries a tenant identifier. One migration serves everyone. Connection pooling behaves normally. Analytics are a simple GROUP BY. Provisioning a customer is an INSERT.

The obvious objection is that isolation now depends on application correctness rather than physical separation. This is a legitimate concern and it is entirely solvable, which is the subject of the next section.

This is the right default for most products. It is what I would start with unless there is a specific reason not to.

Decision 2: How Isolation Is Enforced

If you go with a shared schema, this is the decision that determines whether you sleep well.

The naive approach is to add a tenant filter to every query and rely on code review to catch omissions. This will fail. Not because your team is careless, but because there will eventually be a Friday evening hotfix, and the filter will be the thing that gets forgotten.

Two approaches actually work:

Database-enforced row-level security. Postgres RLS lets you attach a policy to a table so that every query is automatically constrained by a session variable. You set the tenant context when you check out a connection, and the database refuses to return anything else. The security boundary sits below your application code, so a forgotten filter is no longer catastrophic.

An enforced repository layer. If your database does not support RLS, or you are on MongoDB, build a data-access layer that requires a tenant context object to construct any query. Do not export the raw client. If the only way to reach the database is through a function that demands a tenant, the insecure version becomes impossible to write rather than merely discouraged.

The principle in both cases is the same: make the unsafe path unavailable, not just unwise.

Decision 3: Routing and Tenant Resolution

In Next.js this is where middleware earns its place. Every request needs to resolve to a tenant before any data access happens.

Path prefixes — a route segment carrying the tenant slug — are the simplest option. They work locally with no DNS setup, need no wildcard certificate, and are easy to reason about. The downside is that all tenants share an origin, so cookies and browser storage are not isolated by the browser itself.

Subdomains give you real origin isolation, which means the browser enforces cookie separation for you. They look more established to customers. The costs are wildcard DNS, a wildcard TLS certificate, and a local development story that involves editing your hosts file or using a service that resolves wildcards to localhost.

My default is to start with path prefixes and design the tenant resolution so the source of the tenant identifier is a single function. When you later move to subdomains, you change that one function instead of every route.

Whichever you choose, resolve the tenant once in middleware, attach it to the request, and never re-derive it deeper in the stack. Deriving tenant identity in two places is how the two places eventually disagree.

Decision 4: Caching — Where This Usually Breaks

This is the failure mode I would most want to warn you about, because it does not announce itself.

Modern Next.js caches aggressively across several layers: the full route cache, the data cache, and any explicit memoisation you add. Every one of those caches is keyed, and if a key does not include the tenant identifier, you will eventually serve one customer's data to another.

What makes this dangerous is the failure mode. A missing tenant filter in a query usually produces an obvious bug — wrong counts, unexpected rows. A missing tenant segment in a cache key produces correct behaviour almost all the time, and a catastrophic data leak occasionally, under load, in production, for the specific request that happened to warm the cache first.

Practical rules:

  • Every cache key includes the tenant identifier. No exceptions, even when a value looks tenant-independent today.
  • Cache tags are namespaced per tenant, so revalidating one customer's data does not invalidate everyone else's.
  • Static rendering is reserved for genuinely public pages — marketing, docs, pricing. Anything tenant-scoped renders dynamically or uses an explicitly tenant-keyed cache.
  • Write a test that requests the same route as two tenants in sequence and asserts the responses differ. It is a crude test and it catches the exact bug that matters.

What I Would Build on Day One

If I were starting a new SaaS tomorrow, before a single customer existed:

  • Shared database, shared schema, tenant column on every table
  • Row-level security if the database supports it, otherwise a repository layer that cannot be bypassed
  • Path-prefix routing, with tenant resolution isolated in one middleware function
  • A cache-key helper that takes a tenant and refuses to produce a key without one
  • Role-based access control scoped within the tenant, because customers ask for it sooner than you expect

Everything else can wait. Per-tenant theming, custom domains, usage-based billing, tenant-level audit logs — all of that is additive work you can do later without touching your foundations.

The four decisions above are the ones you cannot retrofit cheaply. Spend the extra day on them at the start.

If you are architecting something in this space and want a review before you commit, see how I approach SaaS builds or get in touch.

Frequently Asked Questions

Quick answers to the questions readers ask most.

Only if you have a compliance requirement or a small number of large enterprise customers. Database-per-tenant gives you the strongest isolation and the simplest mental model, but migrations become an orchestration problem and connection pooling gets expensive fast. For most products, a shared database with a tenant column and enforced row-level filtering is the right default.

Subdomains look more professional and give you natural cookie isolation, which is a real security benefit. Path prefixes are far simpler to develop against, need no wildcard DNS or wildcard certificates, and work locally without host-file edits. Start with path prefixes unless you have a specific reason not to. Adding subdomains later is a routing change, not a rewrite.

Never rely on remembering to add a filter to every query. That approach fails the first time someone is in a hurry. Enforce it at a layer that cannot be bypassed: database row-level security policies, or a repository layer that requires a tenant context and refuses to build a query without one. The goal is to make the insecure version impossible to write, not merely discouraged.

Yes, but every cache key must include the tenant identifier. This is the single most dangerous area in a multi-tenant Next.js app, because a cache key collision does not throw an error — it silently serves one tenant's data to another. Audit every use of unstable_cache, revalidateTag and full-route caching for tenant scoping before you launch.

Build the tenant column and the enforcement layer from day one, even with a single customer. Retrofitting tenant scoping into an existing schema and query layer is one of the most painful migrations in software. Everything else — subdomains, per-tenant theming, tenant-level billing — can safely wait until you need it.

Let's talk.

Building production-grade SaaS, AI agents and mobile apps end-to-end.

Hiring for a senior role or have an interesting problem to solve? Drop a note — I read every message.