Getting Started
Build a working multi-tenant API endpoint in 5 minutes.
Not sure which module to adopt first? Start with the package comparison or the Adoption Roadmap.
Prerequisites
| Tool | Version |
|---|---|
| Node.js | ^20.19.0, ^22.12.0, or >=24.0.0 |
| NestJS | 10 or 11 |
| Prisma | 7 recommended; 6 supported by tenancy |
| PostgreSQL | 14+ |
Already configured these dependencies?
Skip to Step 2 only if @nestarc/tenancy, the Prisma 7 PostgreSQL adapter, prisma.config.ts, and the generated client are already in place.
Step 1: Install
npm install @nestarc/tenancy @prisma/client @prisma/adapter-pg pg dotenv
npm install --save-dev prismaThis quick start assumes the generated client and prisma.config.ts are configured as shown in Prisma 7 Setup.
Step 2: Enable RLS
This example assumes an existing, empty users table that matches the model below except for tenant_id. Add the tenant column, index, and Row Level Security as the schema owner:
ALTER TABLE users ADD COLUMN tenant_id TEXT NOT NULL;
CREATE INDEX users_tenant_id_idx ON users (tenant_id);
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant', true)::text);Expose the same table in your Prisma schema, then regenerate the client:
model User {
id String @id @default(uuid())
name String
tenantId String @map("tenant_id")
@@index([tenantId], map: "users_tenant_id_idx")
@@map("users")
}npx prisma generateIf you use Prisma Migrate, put the column, index, and RLS statements in the same migration instead of applying them as ad-hoc SQL.
Existing production rows
ADD COLUMN ... NOT NULL only succeeds on an empty table. For an existing table, add a nullable column, backfill every row from a trusted tenant-ownership source, verify the mapping, and only then apply NOT NULL. Do not assign unrelated rows to a single fallback tenant.
WARNING
Both ENABLE and FORCE are required. Without FORCE, the table owner role bypasses RLS entirely. See 5 Common Multi-Tenancy Pitfalls for details.
Step 3: Register the Module
// app.module.ts
import { Module } from '@nestjs/common';
import { TenancyModule } from '@nestarc/tenancy';
import { PrismaService } from './prisma.service';
import { UsersController } from './users/users.controller';
import { UsersService } from './users/users.service';
@Module({
imports: [
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
}),
],
controllers: [UsersController],
providers: [PrismaService, UsersService],
})
export class AppModule {}Authenticate tenant selection in production
The raw X-Tenant-Id header keeps this local example short, but it does not prove that a caller may access that tenant. In production, derive the tenant from an authenticated JWT claim or cross-check the header against one.
Step 4: Extend Prisma
// prisma.service.ts
import 'dotenv/config';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client';
import { TenancyService, createPrismaTenancyExtension } from '@nestarc/tenancy';
@Injectable()
export class PrismaService implements OnModuleInit {
public readonly client;
constructor(private readonly tenancyService: TenancyService) {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
const basePrisma = new PrismaClient({ adapter });
this.client = basePrisma.$extends(
createPrismaTenancyExtension(tenancyService, {
autoInjectTenantId: true,
// Use the Prisma field name; @map("tenant_id") handles the SQL column.
tenantIdField: 'tenantId',
}),
);
}
async onModuleInit() {
await this.client.$connect();
}
}Run migrations and RLS setup with a schema-owner credential, but connect the application through a separate non-owner, non-superuser role. Have a database administrator or provisioning process run CREATE ROLE (it requires PostgreSQL CREATEROLE or superuser privilege); the migration owner can then apply the grants:
CREATE ROLE app_user LOGIN PASSWORD 'replace-with-a-secret';
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE users TO app_user;Set the runtime DATABASE_URL to that role (for example, postgresql://app_user:...@localhost:5432/app). Keep schema-owner credentials out of the running application.
For separate credentials, point prisma.config.ts at env('MIGRATION_DATABASE_URL') and keep the adapter above on DATABASE_URL. The former owns schema migrations; the latter is the restricted runtime connection.
Step 5: Create an API Endpoint
// users/users.service.ts
import { Injectable } from '@nestjs/common';
import { TenancyService } from '@nestarc/tenancy';
import { PrismaService } from '../prisma.service';
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly tenancy: TenancyService,
) {}
findAll() {
// RLS automatically filters by tenant — no manual WHERE clause needed
return this.prisma.client.user.findMany();
}
create(name: string) {
const tenantId = this.tenancy.getCurrentTenantOrThrow();
return this.prisma.client.user.create({ data: { name, tenantId } });
}
}Prisma's generated UserCreateInput still requires tenantId at compile time, so the service supplies the resolved tenant context rather than accepting it from the request body. The extension overwrites the same field at runtime to prevent body-level spoofing and also covers bulk write paths. In production, authenticate or cross-check that resolved context as described above.
// users/users.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
@Post()
create(@Body('name') name: string) {
return this.usersService.create(name);
}
}Step 6: Test It
# Create a user as tenant A
curl -X POST http://localhost:3000/users \
-H "X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"name": "Alice"}'
# Create a user as tenant B
curl -X POST http://localhost:3000/users \
-H "X-Tenant-Id: 123e4567-e89b-42d3-a456-426614174000" \
-H "Content-Type: application/json" \
-d '{"name": "Bob"}'
# Query as tenant A — only sees Alice
curl http://localhost:3000/users \
-H "X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000"
# => [{"id":"<uuid>","name":"Alice","tenantId":"550e8400-e29b-41d4-a716-446655440000"}]
# Query as tenant B — only sees Bob
curl http://localhost:3000/users \
-H "X-Tenant-Id: 123e4567-e89b-42d3-a456-426614174000"
# => [{"id":"<uuid>","name":"Bob","tenantId":"123e4567-e89b-42d3-a456-426614174000"}]That's it. With the RLS policy and application role configured as above, PostgreSQL filters queries by the current tenant without a manual application-level WHERE clause.
What's Next?
5 min — Standardize your API responses
Add @nestarc/safe-response to auto-wrap all responses with consistent error codes, pagination metadata, and Swagger schemas. Quick Start →
10 min — Add audit logging
Track create, update, and delete operations through an explicit transaction-first write boundary. Quick Start →
30 min — Full tutorial
Build a complete multi-tenant task management API with tenancy + safe-response + testing. Full Tutorial →
See the package comparison and Adoption Roadmap for the recommended adoption path.
Stack Overview
All nestarc packages share a common foundation and compose via Prisma extensions:
Your NestJS App
|-- Request/API layer: safe-response, pagination, idempotency, api-keys
|-- Domain/data layer: tenancy, soft-delete, audit-log, feature-flag, rbac
|-- Events/workers: outbox, webhook, data-subject, jobs
`-- PostgreSQL + Prisma| Package | Role |
|---|---|
| @nestarc/tenancy | Row-level tenant isolation via PostgreSQL RLS |
| @nestarc/safe-response | Standardized API response wrapping + Swagger |
| @nestarc/audit-log | Automatic CUD change tracking |
| @nestarc/feature-flag | DB-based feature flags with tenant overrides |
| @nestarc/soft-delete | Prisma soft-delete with cascade and restore |
| @nestarc/pagination | Cursor + offset pagination with filters |
| @nestarc/idempotency | IETF draft-07-compatible idempotency with response/header replay |
| @nestarc/outbox | Transactional outbox for reliable domain events |
| @nestarc/webhook | Outbound webhook delivery with signing, retry, and logs |
| @nestarc/api-keys | Tenant-scoped machine authentication with scopes, rotation, IP policy, and lifecycle hooks |
| @nestarc/rbac | Tenant-aware roles, permissions, and resource-scoped guards |
| @nestarc/data-subject | GDPR/CCPA export and erase workflows |
| @nestarc/jobs | Tenant-aware jobs with in-memory fairness, durable BullMQ retry/dedupe, and first-party outbox publishing |
Tooling
@nestarc/mcp-guard is published under the same npm scope, but is separate from the NestJS SaaS module stack. It statically scans MCP servers and client configuration files before you connect them to AI coding tools. See mcp-guard.
Prisma Extension Chaining
Multiple nestarc packages compose as Prisma extensions:
const prisma = basePrisma
.$extends(createPrismaTenancyExtension(tenancyService, {
autoInjectTenantId: true,
tenantIdField: 'tenantId',
interactiveTransactionSupport: true,
}))
.$extends(createPrismaSoftDeleteExtension({ softDeleteModels: ['User'] }))
.$extends(createAuditExtension({
consistency: 'atomic-required',
trackedModels: ['User'],
prismaModule,
}));
await prisma.withAuditTransaction((tx) =>
tx.user.update({ where: { id: userId }, data: { name: 'After' } }),
);INFO
Extension order matters. audit-log 0.4 requires an explicit consistency mode, and authoritative ordinary CUD tracking runs inside withAuditTransaction(). This multi-tenant example opts into tenancy's interactive-transaction support; validate it against your exact Prisma version. audit-log's documented atomic soft-delete bridge is not yet exposed by the currently published soft-delete 0.6 package, so keep lifecycle auditing on the event/manual-log path until a compatible release is available. In Prisma 7, audit-log also needs the generated { Prisma } namespace as prismaModule; soft-delete needs explicit DMMF when cascade or relation filters are enabled. See the Prisma Extension Chaining guide for details.