Multi-tenancy comes up early when you're building a SaaS for businesses. The question isn't just "how do we store data for multiple customers" — it's "how do we isolate it reliably, migrate it at scale, and not make the app code miserable to work with."

There are three common approaches. Each has a different point of failure.

Option 1: Row-level (tenant_id on every table)

Every table has a tenant_id column. Every query filters by it. Simple to set up, cheapest to operate, but the worst blast radius if you forget a WHERE clause — tenant A can read tenant B's data.

The fix for the "forgot the WHERE clause" problem is to never rely on developers remembering. Use Prisma middleware to inject the tenant filter automatically:

// tenant.middleware.ts
prisma.$use(async (params, next) => {
    const tenantId = tenantContext.get(); // from AsyncLocalStorage

    const tenantedModels = ["Device", "Alert", "Config"]; // tables with tenant_id
    if (tenantedModels.includes(params.model ?? "")) {
        if (params.action === "findMany" || params.action === "findFirst") {
            params.args = params.args ?? {};
            params.args.where = { ...params.args.where, tenantId };
        }
        if (params.action === "create") {
            params.args.data.tenantId = tenantId;
        }
    }

    return next(params);
});

The middleware reads the current tenant from AsyncLocalStorage, which a NestJS interceptor populates at the start of each request:

@Injectable()
export class TenantInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler) {
        const req = context.switchToHttp().getRequest();
        const tenantId = req.user?.tenantId; // from JWT claim

        return tenantContext.run(tenantId, () => next.handle());
    }
}

For NeSecure — managing 15,000+ edge devices across enterprise customers — this is what we use. Each customer's devices, alerts, and configs are row-isolated. The middleware handles filtering transparently. App code never thinks about tenantId explicitly.

Pick this when: many tenants (dozens to thousands), similar data volumes per tenant, you can trust the middleware injection, Postgres row-level security as a secondary safety net.

Option 2: Schema-per-tenant

Postgres supports multiple schemas within a single database. Each tenant gets its own schema with its own copy of every table. You switch search_path per request to route queries to the right schema.

-- Set the search path at the start of each connection
SET search_path TO tenant_abc, public;
-- Now all unqualified queries hit tenant_abc's tables

Better isolation — a misconfigured query can't cross tenants because the wrong tables simply don't exist in the current search path. The downside is migrations: when you alter a table, you have to run the migration across every schema. At 50 tenants this is manageable. At 500 it needs automation and careful rollout ordering.

Pick this when: fewer tenants, larger data per tenant, you need schema-level isolation for compliance, and you're willing to invest in migration tooling.

Option 3: Database-per-tenant

Each tenant gets a separate Postgres database (or RDS instance). Maximum isolation — one tenant's load can't affect another's query performance, no possibility of cross-tenant data leakage.

The problem is connection pools. Node.js uses connection pools per database. At 10 tenants with 20 connections each, you're at 200 connections. At 100 tenants you're at 2,000. Postgres starts struggling past a few hundred active connections, and PgBouncer adds operational complexity.

This approach works when you can count your tenants on two hands and each one is paying significant enterprise revenue. We're not there with NeSecure, so we didn't go this route.

Pick this when: a small number of high-value tenants, enterprise compliance requirements, dedicated SLAs per customer, and you can justify the operational overhead.

The honest summary

Row-level with Prisma middleware is the right default. It's not the most isolated option, but the isolation failure mode (forgotten WHERE clause) is eliminated by the middleware, and it scales to many tenants without operational complexity.

If you're building for HIPAA/SOC2 customers who need contractual data isolation — schema-per-tenant. If you're building for three enterprise accounts and they're each paying $10k/month — database-per-tenant.

Don't over-architect this for a product that isn't launched yet.