← Writing
Backend
July 4, 2026 · 5 min read42

Multi-Tenant SaaS Architecture with NestJS + PostgreSQL: Sharing a Building Without Sharing a Toothbrush

A practical, slightly irreverent tour of building multi-tenant SaaS with NestJS and PostgreSQL: tenant resolution, schema strategies, row-level security, and connection pooling.

Abdulboriy Malikov

Multi-Tenant SaaS Architecture with NestJS + PostgreSQL: Sharing a Building Without Sharing a Toothbrush

So you want to build a SaaS product. Congratulations, you have just signed up for the eternal struggle of keeping a hundred companies' data in the same building without letting them borrow each other's stapler. That's multi-tenancy in a nutshell, and today we're building it with NestJS and PostgreSQL, two technologies that get along so well they might as well be roommates.

Let's start with the big question every architecture diagram avoids: how much do your tenants actually share?

OPTION ONE: DATABASE PER TENANT

Every customer gets their own PostgreSQL database. It's the everyone-gets-their-own-apartment model. Isolation is fantastic, backups are simple, and if one tenant's data explodes at 3am, only their database catches fire. The catch is that you now manage connections, migrations, and backups times however many tenants you have. Great for enterprise customers with strict compliance needs, painful once you have a thousand small tenants.

OPTION TWO: SCHEMA PER TENANT

This is the shared apartment building where everyone has their own unit. One PostgreSQL instance, one database, but a separate schema per tenant, something like tenant_acme and tenant_globex sitting side by side. NestJS can switch schemas per request using middleware that sets the search_path based on the incoming tenant identifier. It's a nice middle ground, though schema sprawl becomes its own chore once you're running migrations across five hundred schemas before your coffee gets cold.

OPTION THREE: SHARED SCHEMA WITH A TENANT_ID COLUMN

Everybody lives in one big open-plan office, and every table has a tenant_id column quietly making sure nobody reads someone else's sticky notes. This is the cheapest and most scalable option, and it pairs beautifully with PostgreSQL row-level security, which we'll get to shortly.

For most SaaS products starting out, the shared schema approach wins because it keeps your infrastructure boring, and boring infrastructure is a compliment in this industry.

BUILDING THE TENANT RESOLVER IN NESTJS

The first real piece of engineering is figuring out who is asking. Usually this comes from a subdomain like acme.yourapp.com, a header like x-tenant-id, or a JWT claim once the user is authenticated. A NestJS middleware is the natural place for this: a TenantMiddleware runs before your route handlers, reads the tenant identifier from the request, looks it up against a tenants table, and attaches it to the request object as request.tenantId.

Request-scoped providers are the unsung heroes here. NestJS lets you mark a provider as Scope.REQUEST, meaning a fresh instance is created for every incoming request, carrying that tenant context along for the ride. Inject this TenantContext into your services and repositories, and suddenly your queries know exactly whose data they're allowed to touch.

MAKING POSTGRESQL ENFORCE THE RULES, NOT JUST TRUST YOU

Here's the fun part. If you only rely on adding a tenant_id filter to every query by hand, you're one forgotten WHERE clause away from a very uncomfortable incident report. This is where PostgreSQL row-level security becomes your best friend. You enable RLS on a table, write a policy that compares tenant_id to a session variable, and the database itself refuses to hand out rows that don't belong to the current tenant, even if your application code has a bad day.

The pattern looks like this: after your middleware identifies the tenant, you run a statement that sets a Postgres session variable for the current transaction. Your RLS policies reference that setting to filter rows automatically. TypeORM and Prisma both support running raw statements at the start of a transaction, so you can wire this into an interceptor that wraps every request in a transaction, sets the tenant variable, and lets Postgres do the bouncer work.

CONNECTION POOLING, OR HOW NOT TO SUMMON A THOUSAND CLIENTS

Multi-tenancy has a habit of multiplying your database connections if you're not careful, especially with the schema-per-tenant approach. PgBouncer becomes essential here, sitting between your NestJS app and Postgres, pooling connections so thousands of tenant requests don't turn into thousands of literal database connections. Transaction-mode pooling works nicely with the shared-schema plus RLS approach since every request is short-lived and stateless once the tenant variable is set.

MIGRATIONS WITHOUT WAKING UP THE WHOLE NEIGHBORHOOD

With shared schema, migrations are simple: you run them once and every tenant benefits immediately. With schema-per-tenant, you need a migration runner that loops through every tenant schema, which is exactly the kind of script you write once, test twice, and still watch nervously the first time it runs against production.

WRAPPING IT UP

There is no universally correct answer between database-per-tenant, schema-per-tenant, and shared schema with row-level security. Pick based on how strict your isolation requirements are, how many tenants you expect, and how much operational complexity your team enjoys on a Friday afternoon. NestJS gives you clean primitives, middleware, guards, interceptors, and request scoping, to make tenant resolution elegant, while PostgreSQL gives you the actual enforcement muscle through row-level security. Put them together thoughtfully, and your SaaS can comfortably host thousands of tenants who will never even know their neighbors exist.