Back to Journal
SaaS Engineering

Multi-Tenant Architecture Best Practices for Startup Teams

Battle-tested best practices for Multi-Tenant Architecture tailored to Startup teams, including anti-patterns to avoid and a ready-to-use checklist.

Muneer Puthiya Purayil 17 min read

Startups building multi-tenant SaaS need to ship fast without painting themselves into an architectural corner. The right multi-tenancy foundation takes a day to implement and scales to your first 1,000 customers without re-architecture. These practices focus on the simplest patterns that avoid the most expensive mistakes.

Start with Shared Database + Row-Level Security

For startups, a single database with tenant_id on every table is the right starting point. It's the simplest to develop against, deploy, and monitor.

sql
1-- Every table includes tenant_id
2CREATE TABLE orders (
3 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 tenant_id UUID NOT NULL REFERENCES tenants(id),
5 product_id UUID NOT NULL,
6 quantity INTEGER NOT NULL,
7 total_cents INTEGER NOT NULL,
8 status VARCHAR(50) DEFAULT 'pending',
9 created_at TIMESTAMPTZ DEFAULT NOW()
10);
11 
12CREATE INDEX idx_orders_tenant ON orders(tenant_id);
13 
14-- Row-Level Security
15ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
16 
17CREATE POLICY tenant_isolation ON orders
18 USING (tenant_id = current_setting('app.current_tenant')::uuid);
19 
20-- Force RLS for application user
21ALTER TABLE orders FORCE ROW LEVEL SECURITY;
22 
typescript
1// Middleware to set tenant context
2async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
3 const tenantId = extractTenantId(req);
4 if (!tenantId) {
5 return res.status(400).json({ error: "Tenant not identified" });
6 }
7 
8 const pool = getPool();
9 const client = await pool.connect();
10 await client.query("SET app.current_tenant = $1", [tenantId]);
11 
12 req.dbClient = client;
13 req.tenantId = tenantId;
14 
15 res.on("finish", () => client.release());
16 next();
17}
18 
19function extractTenantId(req: Request): string | null {
20 // Option 1: From JWT
21 if (req.user?.tenantId) return req.user.tenantId;
22 // Option 2: From subdomain
23 const host = req.hostname;
24 if (host.includes(".")) return host.split(".")[0];
25 // Option 3: From header
26 return req.headers["x-tenant-id"] as string || null;
27}
28 

RLS handles the isolation automatically — even if application code forgets to include WHERE tenant_id = ?, PostgreSQL enforces the policy. This is the single most important safety net for multi-tenant startups.

Tenant Provisioning

typescript
1interface CreateTenantRequest {
2 name: string;
3 slug: string;
4 ownerEmail: string;
5 plan: "free" | "pro" | "business";
6}
7 
8async function provisionTenant(req: CreateTenantRequest): Promise<Tenant> {
9 const tenant = await db.query(
10 \`INSERT INTO tenants (name, slug, plan, status)
11 VALUES ($1, $2, $3, 'active') RETURNING *\`,
12 [req.name, req.slug, req.plan]
13 );
14 
15 // Create owner user
16 await db.query(
17 \`INSERT INTO users (tenant_id, email, role)
18 VALUES ($1, $2, 'owner')\`,
19 [tenant.rows[0].id, req.ownerEmail]
20 );
21 
22 // Set up default data
23 await seedTenantDefaults(tenant.rows[0].id);
24 
25 return tenant.rows[0];
26}
27 
28async function seedTenantDefaults(tenantId: string): Promise<void> {
29 await db.query("SET app.current_tenant = $1", [tenantId]);
30 // Create default categories, settings, etc.
31 await db.query(
32 \`INSERT INTO settings (tenant_id, key, value)
33 VALUES ($1, 'timezone', 'UTC'),
34 ($1, 'currency', 'USD'),
35 ($1, 'notifications_enabled', 'true')\`,
36 [tenantId]
37 );
38}
39

Simple Rate Limiting

typescript
1import { RateLimiterRedis } from "rate-limiter-flexible";
2import Redis from "ioredis";
3 
4const redis = new Redis(process.env.REDIS_URL);
5 
6const tenantLimiters: Record<string, RateLimiterRedis> = {};
7 
8function getTenantLimiter(plan: string): RateLimiterRedis {
9 const limits: Record<string, { points: number; duration: number }> = {
10 free: { points: 100, duration: 60 }, // 100 req/min
11 pro: { points: 1000, duration: 60 }, // 1000 req/min
12 business: { points: 10000, duration: 60 }, // 10000 req/min
13 };
14 
15 if (!tenantLimiters[plan]) {
16 const config = limits[plan] || limits.free;
17 tenantLimiters[plan] = new RateLimiterRedis({
18 storeClient: redis,
19 keyPrefix: \`rl:\${plan}\`,
20 points: config.points,
21 duration: config.duration,
22 });
23 }
24 return tenantLimiters[plan];
25}
26 
27async function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) {
28 const limiter = getTenantLimiter(req.tenant.plan);
29 try {
30 await limiter.consume(req.tenantId);
31 next();
32 } catch {
33 res.status(429).json({ error: "Rate limit exceeded" });
34 }
35}
36

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Anti-Patterns to Avoid

Building database-per-tenant from day one. The operational overhead of managing separate databases is enormous for a startup. Start with shared schema + RLS. You can always migrate premium customers to dedicated databases later.

Tenant ID as a URL path parameter. GET /tenants/123/orders leaks tenant IDs in URLs, server logs, and referrer headers. Use JWT claims or subdomain-based routing instead.

No RLS or equivalent enforcement. Relying on application code to always include WHERE tenant_id = ? is a data breach waiting to happen. Use PostgreSQL RLS or equivalent database-level enforcement.

Over-engineering isolation before you have customers. A startup with 10 tenants doesn't need sharding, dedicated databases, or per-tenant encryption keys. Add complexity as specific customer requirements demand it.

Production Checklist

  • tenant_id column on every table with foreign key constraint
  • PostgreSQL RLS enabled and forced on all tenant tables
  • Tenant resolution middleware (JWT, subdomain, or header)
  • Automated tenant provisioning (database setup, default data)
  • Per-tenant rate limiting by plan tier
  • Tenant-scoped API responses (no cross-tenant data leakage)
  • Tenant deletion capability (data export then purge)
  • Basic per-tenant usage tracking
  • Index on tenant_id for every table

Conclusion

Startup multi-tenancy should be simple enough to implement in a day and robust enough to serve your first 1,000 customers. PostgreSQL RLS provides the strongest isolation guarantee with the least code — a single policy on each table prevents cross-tenant data access regardless of application bugs. Combined with JWT-based tenant resolution and plan-based rate limiting, this foundation scales to meaningful revenue before re-architecture becomes necessary.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026