Dashboard

How to Add Multi-Tenant Support to an AI-Built App

The three real ways to add multi-tenancy to an AI-built app, shared schema, schema-per-tenant, database-per-tenant, with a decision table and a working Postgres row-level security pattern.

Steve Jefferson
Steve Jefferson
Developer Advocate
20 September 20261 min read

Adding multi-tenant support to an AI-built app means picking one of three real architecture choices: a shared schema with a tenant_id column on every table, a separate schema per tenant inside one database, or a fully separate database per tenant. Most AI-built apps, the kind generated quickly with a coding agent and a single Postgres database, are best served by the shared-schema approach with row-level security enforcing isolation. Below is a breakdown of all three options, a decision table by team size and compliance need, and a working RLS policy pattern you can drop into a Supabase or plain Postgres project.

Three Ways to Add Multi-Tenancy

Every multi-tenant architecture is a variation on how much isolation lives in the schema versus how much lives in application logic. More isolation in the schema means fewer ways for a bug to leak one customer's data into another's, at the cost of more operational overhead. This decision usually comes up right after the first version of an app is working, once the initial build needs to support more than one customer. Here are the three options, in order of how much they lean on the database to do the isolating.

Shared Schema With a tenant_id Column

Every table gets a tenant_id column, every query filters on it, and all tenants live in the same tables in the same database. This is the default most AI coding agents reach for when you ask for "multi-tenant support" without specifying anything else, because it requires the smallest schema change: one column, one foreign key, one index per table. It also scales to thousands of tenants without provisioning new infrastructure for each one, which matters if you expect a long tail of small customers rather than a handful of large ones.

The risk is that isolation now depends on every single query remembering to filter by tenant_id. Miss it once, in one endpoint, in one background job, and you have a cross-tenant data leak. That risk is exactly what row-level security exists to close, covered below.

Schema-Per-Tenant

Each tenant gets its own Postgres schema inside the same database, with identical table structures duplicated per schema. Isolation is stronger than shared-schema, because a query connected under the wrong schema simply can't see another tenant's tables. Migrations get more complicated, though: every schema change now has to run once per tenant schema, and at a few hundred tenants that migration step starts taking real time and needs its own tooling.

This model tends to fit a mid-size B2B product with a bounded, known set of tenants, somewhere in the dozens to low hundreds, especially when a subset of customers wants a stronger data-separation story for their own compliance reasons without paying for fully separate infrastructure.

Database-Per-Tenant

Each tenant gets an entirely separate database, sometimes on separate infrastructure. This is the strongest isolation available and the easiest to explain in a security questionnaire, since "your data lives in a database no other customer can reach" is a one-sentence answer that satisfies most procurement teams. It's also the most expensive to operate: connection pooling, backups, monitoring, and migrations all multiply by tenant count, and cross-tenant analytics (anything that needs to look across all customers at once) requires a separate aggregation pipeline instead of a simple query.

This is the right call for enterprise and regulated customers, healthcare, finance, government, where contractual or legal requirements demand physical data separation, not just logical separation enforced by application code.

Which Option Fits Your Team

Small team, early-stage product, tenants are mostly small businesses or individuals: shared schema with RLS. You get real isolation without the operational cost of managing per-tenant infrastructure, and it's the option an AI coding agent can implement correctly in a single migration.

Growing team, dozens to low hundreds of tenants, a few customers asking pointed questions about data separation: schema-per-tenant. You get a stronger isolation story for the customers who care, while keeping one set of infrastructure to operate.

Regulated industry, contracts that specify physical data separation, or enterprise customers running their own security review: database-per-tenant. Budget for the operational overhead up front, it doesn't shrink as you add customers, it multiplies.

If you're not sure which bucket you're in yet, default to shared schema with RLS and migrate later. It's a much smaller jump from shared schema to schema-per-tenant than it is to retrofit tenant isolation onto an app that never had it, which is a project worth understanding before real traffic exposes the gaps in whatever you shipped first.

Row-Level Security for the Shared-Schema Approach

This is the pattern that makes shared-schema safe instead of merely convenient. Row-level security (RLS) is a Postgres feature that rewrites every query against a protected table to add a tenant filter automatically, enforced by the database itself rather than by application code remembering to add a WHERE clause. If a query doesn't satisfy the policy, Postgres behaves as if the other tenants' rows don't exist, full stop, regardless of what the application layer does or forgets to do.

Step one, add the column and index it, since it becomes the leading predicate on nearly every query against the table:

ALTER TABLE orders ADD COLUMN tenant_id uuid NOT NULL; CREATE INDEX orders_tenant_id_idx ON orders (tenant_id);

Step two, enable and force row-level security on the table. "Force" matters here, without it the table owner role bypasses RLS entirely, which defeats the point if your application connects as that role:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY; ALTER TABLE orders FORCE ROW LEVEL SECURITY;

Step three, create the policy. This example uses a session-local setting for the current tenant, which your application sets once per connection or transaction:

CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);

The USING clause filters what a query can read, the WITH CHECK clause blocks inserts or updates that would write a row belonging to a different tenant. Both matter, a policy with only USING will silently let a buggy insert write cross-tenant data even though reads stay isolated.

Step four, your application sets the session variable at the start of each request, scoped to the current transaction so it can't leak between pooled connections:

SELECT set_config('app.current_tenant_id', $1, true);

The third argument, true, scopes the setting to the current transaction rather than the whole session, which matters if you're using a connection pooler like PgBouncer or Supavisor, since connections get reused across requests and a session-scoped setting would leak into the next request on that connection.

Common Mistakes When Retrofitting Multi-Tenancy

A few things that trip up AI-built apps specifically, because a coding agent asked to "add multi-tenant support" will often do the obvious first 80% and miss the rest. Child tables get forgotten: if orders has a tenant_id but order_items doesn't, a join can leak data through the child table even when the parent is protected. Every tenant-owned table needs its own tenant_id and its own policy, not just the top-level ones.

Background jobs and cron tasks often run outside a normal request context, so the code that sets the tenant session variable on every HTTP request doesn't run for them. Any code path that touches the database, including migrations, needs a plan for how tenant context gets set, or it needs to run as a role that bypasses RLS deliberately and filters manually with extra care.

And idempotency handling needs tenant scoping too: an idempotency key that's unique per operation but not scoped to tenant_id can cause one tenant's retried request to collide with a different tenant's key, especially once you add pagination and cursors that also need to stay tenant-scoped to avoid leaking row counts or ordering information across tenants.

For a worked, correct row-level security policy example on the shared-schema approach, how to add multi-tenancy to an AI-built app walks through the same three-pattern decision from a slightly different angle.

What's the difference between multi-tenant and single-tenant architecture?

Single-tenant means one deployment, one database, one set of infrastructure per customer. Multi-tenant means many customers share the same application and, usually, the same database, with logical separation enforced through a tenant identifier and access policies rather than physically separate infrastructure per customer.

Is row-level security enough to isolate tenants in Postgres?

For the shared-schema model, yes, RLS enforced with FORCE ROW LEVEL SECURITY and a WITH CHECK clause on every policy gives you database-enforced isolation that holds even if application code has a bug. It's not a replacement for schema-per-tenant or database-per-tenant when a contract or regulation specifically requires physical separation, but for the large majority of AI-built apps it's the right level of isolation for the operational cost.

Do I need a separate database for every tenant?

Only if you're in a regulated industry or a customer contract specifically requires physical data separation. For most products, shared schema with RLS or schema-per-tenant covers the actual isolation requirement without the operational overhead of provisioning and maintaining a database per customer.

How do I migrate an existing single-tenant app to multi-tenant?

Add a tenant_id column to every table that stores tenant-owned data, backfill it for existing rows, add the RLS policies and the session-variable plumbing described above, and audit every query path, including background jobs, for whether it sets tenant context correctly. Do this before you have more than one real tenant if at all possible, retrofitting isolation onto a database with live cross-tenant assumptions baked into its queries is meaningfully harder than building it in from the first migration.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.

How to Add Multi-Tenant Support to an AI-Built App | swarmz.net