
Multi-Tenant Architecture: How Organizations Work
Multi-Tenant Architecture: How Organizations Work
One of the most critical decisions when building a B2B SaaS product is how to handle multi-tenancy. Should each customer get their own database? A shared database with row-level security? How do you handle teams, permissions, and billing?
In this post, we'll explore the multi-tenant architecture powering our boilerplate and the decisions behind it.
What is Multi-Tenancy?
Multi-tenancy is the ability to serve multiple customers (tenants) from a single application instance while keeping their data isolated and secure. In B2B SaaS, a "tenant" is typically an organization or company.
Our Approach: Shared Database, Organization Isolation
We use a shared database with organization-scoped queries approach. Here's why:
Advantages
- Cost-effective: One database to maintain, not hundreds
- Easy to scale: Horizontal scaling through database replication
- Feature rollout: Deploy new features to all customers instantly
- Analytics: Run cross-tenant analytics for insights
- Resource efficiency: Shared connection pools and caching
Data Isolation
Every organization-scoped table includes an organizationId foreign key:
CREATE TABLE team_members (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL REFERENCES organizations(id),
user_id UUID NOT NULL REFERENCES users(id),
role VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Our middleware automatically filters queries by the current organization:
// Get current organization from session
const { organizationId } = await getSession();
// All queries automatically scoped
const members = await db
.select()
.from(teamMembers)
.where(eq(teamMembers.organizationId, organizationId));
Database Schema
Core Entities
Users → The individual people using your platform
- Can belong to multiple organizations
- Have their own authentication credentials
- Store personal preferences and settings
Organizations → The tenant/customer entities
- Have their own billing and subscription
- Own all organization-scoped data
- Configure features and settings
Organization Memberships → The many-to-many relationship
- Links users to organizations
- Stores role information (owner, admin, user)
- Tracks invitation status
Role-Based Access Control
We implement three permission levels:
- Owner: Full control including billing and deletion
- Admin: Manage team members and settings
- User: Standard access to features
Roles are enforced at both the API and database level:
// API middleware checks role
export const withAdminRequired = (handler) => {
return async (req, context) => {
const membership = await getMembership(req);
if (!['owner', 'admin'].includes(membership.role)) {
return new Response('Forbidden', { status: 403 });
}
return handler(req, context);
};
};
Billing Architecture
Billing is attached to organizations, not users. This makes sense for B2B:
interface Organization {
id: string;
name: string;
// Billing fields
planId: string;
stripeCustomerId?: string;
stripeSubscriptionId?: string;
subscriptionStatus?: string;
// Quotas enforced at org level
maxUsers: number;
maxProjects: number;
maxStorageGB: number;
}
When a user switches organizations, they automatically get that organization's plan features and quotas.
Switching Organizations
Users can belong to multiple organizations and switch between them:
// Organization switcher component
const organizations = await getUserOrganizations(userId);
// Switch context
await setCurrentOrganization(organizationId);
The application re-routes to /org/{slug}/dashboard and all subsequent queries are scoped to the new organization.
Security Considerations
Row-Level Security
Every query validates organization access:
// ❌ Bad: No organization check
const data = await db.select().from(projects);
// ✅ Good: Organization-scoped
const data = await db
.select()
.from(projects)
.where(eq(projects.organizationId, orgId));
API Route Protection
Three levels of middleware:
withAuthRequired- User must be logged inwithOrganizationAuthRequired- User must belong to organizationwithSuperAdminAuthRequired- User must be super admin
Audit Logging
All sensitive actions are logged:
await createAuditLog({
organizationId,
userId,
action: 'member.removed',
metadata: { removedUserId, role },
});
Performance Optimization
Database Indexes
Every organization-scoped table has an index:
CREATE INDEX idx_projects_org_id
ON projects(organization_id);
Query Patterns
We use Drizzle ORM's query builder for type-safe, efficient queries:
// Efficient join with organization scope
const projectsWithMembers = await db
.select()
.from(projects)
.leftJoin(memberships, eq(memberships.projectId, projects.id))
.where(eq(projects.organizationId, orgId));
Scaling Considerations
As your application grows:
- Connection pooling: Use PgBouncer or Neon's built-in pooling
- Read replicas: Route analytics queries to replicas
- Caching: Cache organization settings and quotas
- Sharding: Eventually shard by organization ID if needed
Try It Yourself
Our boilerplate includes:
- Complete multi-tenant schema
- Organization switcher UI
- Role-based middleware
- Billing integration
- Team invitation system
Clone the repository and see it in action:
git clone https://github.com/yourusername/b2b-boilerplate.git
pnpm install
pnpm dev
Multi-tenancy doesn't have to be complicated. With the right architecture from day one, you can scale to thousands of organizations without major refactoring.