I’ve been thinking about this a lot lately. Building software that serves multiple customers from a single codebase isn’t just a technical challenge—it’s a business necessity. Every time I start a new SaaS project, the question of how to keep each customer’s data separate and secure comes up. It’s the foundation everything else is built on. Today, I want to walk you through how I approach this using Hapi.js and Knex.js. This isn’t theory; it’s the exact system I’ve used in production.
Let’s start with the basics. Multi-tenancy means one application serves many customers. Think of it like an apartment building. Everyone lives in the same structure, but each apartment is private. The key is making sure no one can see into their neighbor’s space. How do we build digital walls that are both strong and efficient?
There are three main ways to do this. The first puts everyone in the same room with name tags. All data goes into the same database tables, and a tenant_id column tells us who owns what. It’s simple but risky. One wrong query could show someone else’s information.
The second method gives each tenant their own schema within the same database. It’s like giving each apartment its own locked storage unit in the building’s basement. This is the approach I prefer for most projects. It offers a good balance of security and manageability.
The third method gives each tenant a completely separate database. Maximum security, but also maximum complexity and cost. For most growing applications, the schema-per-tenant approach hits the sweet spot.
Ready to build something real? Let’s set up our project. You’ll need Node.js, PostgreSQL, and TypeScript. Here’s how I start every project:
mkdir my-saas-backend
cd my-saas-backend
npm init -y
npm install @hapi/hapi knex pg
npm install -D typescript @types/node
The structure matters. I organize code by what it does, not what it is. Here’s my typical folder layout:
src/
├── middleware/ # Handles tenant detection
├── services/ # Business logic
├── routes/ # API endpoints
├── plugins/ # Hapi.js plugins
└── utils/ # Shared helpers
Configuration comes next. I use environment variables for anything that changes between setups. Create a .env file:
DATABASE_URL=postgresql://user:pass@localhost:5432/saas_db
JWT_SECRET=your-secret-here
SERVER_PORT=3000
Now, let’s build our server. Hapi.js makes this straightforward with its plugin system. I create a plugin specifically for handling tenant isolation. This plugin runs on every request, figuring out which tenant is making the call.
// src/plugins/tenant-isolation.ts
import { Plugin } from '@hapi/hapi';
const tenantIsolationPlugin: Plugin<{}> = {
name: 'tenant-isolation',
register: async (server) => {
server.ext('onPreHandler', (request, h) => {
// Extract tenant from subdomain, header, or JWT
const tenantId = request.headers['x-tenant-id']
|| request.query.tenant;
if (!tenantId) {
throw new Error('Tenant identification required');
}
// Store tenant context for this request
request.app.tenantId = tenantId;
return h.continue;
});
}
};
This simple middleware runs before any route handler. It looks for tenant information in headers or query parameters. In a real application, you’d want to validate this against a database of active tenants. What happens if someone provides a fake tenant ID?
Database setup is where Knex.js shines. We need to configure it to work with multiple schemas. Here’s my database configuration:
// src/config/database.ts
import knex from 'knex';
export const getKnexConfig = (tenantSchema?: string) => {
return {
client: 'pg',
connection: process.env.DATABASE_URL,
searchPath: tenantSchema ? [tenantSchema, 'public'] : ['public'],
pool: {
min: 2,
max: 10
}
};
};
// Create a tenant-specific connection
export const getTenantConnection = (tenantId: string) => {
return knex(getKnexConfig(`tenant_${tenantId}`));
};
Notice the searchPath setting? This is PostgreSQL’s way of saying “look in this schema first.” When we set it to tenant_123, all our queries automatically use that tenant’s tables. No need to prefix every table name with the schema.
But where do these schemas come from? We need to create them. Here’s a service that handles schema management:
// src/services/tenant-schema.service.ts
import { knex } from '../config/database';
export class TenantSchemaService {
async createTenantSchema(tenantId: string): Promise<void> {
const schemaName = `tenant_${tenantId}`;
// Create the schema
await knex.raw(`CREATE SCHEMA IF NOT EXISTS ??`, [schemaName]);
// Run migrations in the new schema
await this.runMigrationsForSchema(schemaName);
}
private async runMigrationsForSchema(schemaName: string): Promise<void> {
// Get base migration SQL
const migrations = await this.getTemplateMigrations();
// Execute each migration in the tenant's schema
const tenantKnex = knex({
...knex.client.config,
searchPath: [schemaName]
});
for (const migration of migrations) {
await tenantKnex.raw(migration.sql);
}
}
}
When a new customer signs up, this service creates their private schema and sets up all the necessary tables. Each tenant gets their own complete set of tables, identical in structure but separate in data.
Query building needs special attention. We must never forget which tenant we’re working with. I wrap Knex.js queries to automatically add tenant filtering:
// src/services/query-builder.service.ts
export class QueryBuilderService {
constructor(private tenantId: string) {}
async getUsers(filters: any = {}) {
const db = getTenantConnection(this.tenantId);
let query = db('users').select('*');
// Always filter by tenant (extra safety)
query = query.where('tenant_id', this.tenantId);
// Apply any additional filters
if (filters.active !== undefined) {
query = query.where('is_active', filters.active);
}
return query;
}
async createUser(userData: any) {
const db = getTenantConnection(this.tenantId);
// Automatically add tenant_id to all inserts
const dataWithTenant = {
...userData,
tenant_id: this.tenantId,
created_at: new Date()
};
return db('users').insert(dataWithTenant).returning('*');
}
}
This might seem like overkill since we’re using separate schemas, but I add the tenant_id filter anyway. It’s a defensive practice. If somehow a query runs against the wrong schema, the tenant_id check provides another layer of protection.
Migrations require special handling. We need to run them across all tenant schemas. Here’s how I manage this:
// src/services/migration.service.ts
export class MigrationService {
async runForAllTenants(): Promise<void> {
// Get all active tenants
const tenants = await this.getAllTenants();
for (const tenant of tenants) {
console.log(`Migrating tenant: ${tenant.id}`);
await this.runForTenant(tenant.id);
}
}
async runForTenant(tenantId: string): Promise<void> {
const db = getTenantConnection(tenantId);
// Check if migrations table exists
const hasMigrationsTable = await db.schema.hasTable('migrations');
if (!hasMigrationsTable) {
await this.createMigrationsTable(db);
}
// Run pending migrations
await this.executePendingMigrations(db);
}
}
Performance is critical. Connection pooling becomes even more important with multiple schemas. Each tenant connection should reuse connections when possible. How do we prevent one tenant’s slow query from affecting others?
I use a connection pool manager that limits connections per tenant:
// src/utils/connection-pool.ts
class ConnectionPoolManager {
private pools: Map<string, any> = new Map();
private maxConnectionsPerTenant = 5;
getConnection(tenantId: string) {
if (!this.pools.has(tenantId)) {
this.pools.set(tenantId, this.createPool(tenantId));
}
return this.pools.get(tenantId);
}
private createPool(tenantId: string) {
return knex({
client: 'pg',
connection: process.env.DATABASE_URL,
searchPath: [`tenant_${tenantId}`],
pool: {
min: 1,
max: this.maxConnectionsPerTenant
}
});
}
}
This ensures no single tenant can consume all database connections. Each gets their own pool with limits.
Testing requires careful planning. We need to test that data doesn’t leak between tenants. Here’s a test I always include:
// tests/tenant-isolation.test.ts
describe('Tenant Isolation', () => {
it('should not leak data between tenants', async () => {
// Create test data for tenant A
await createUser('tenant_a', { name: 'Alice' });
// Try to access from tenant B
const tenantBUsers = await getUsers('tenant_b');
// Should be empty
expect(tenantBUsers).toHaveLength(0);
});
it('should handle concurrent tenant requests', async () => {
// Simulate multiple tenants accessing simultaneously
const promises = [
getUsers('tenant_1'),
getUsers('tenant_2'),
getUsers('tenant_3')
];
const results = await Promise.all(promises);
// Each should only see their own data
results.forEach((result, index) => {
expect(result.tenantId).toBe(`tenant_${index + 1}`);
});
});
});
Monitoring is different in a multi-tenant system. We need to track performance per tenant. I add tenant context to all logs:
// src/middleware/logging.ts
server.ext('onPreResponse', (request, h) => {
const response = request.response;
console.log({
timestamp: new Date().toISOString(),
tenantId: request.app.tenantId,
method: request.method,
path: request.path,
statusCode: response instanceof Error ? 500 : response.statusCode,
duration: Date.now() - request.info.received
});
return h.continue;
});
This lets me see if one tenant is experiencing more errors or slower responses than others. It’s essential for maintaining quality of service across all customers.
Security considerations are paramount. Beyond data isolation, we need proper authentication and authorization. Each tenant might have different permission schemes. I implement a role-based system that’s tenant-aware:
// src/middleware/auth.ts
const validateToken = async (request, token) => {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if token matches requested tenant
if (decoded.tenantId !== request.app.tenantId) {
throw new Error('Token tenant mismatch');
}
// Check if user has access to this tenant
const hasAccess = await checkTenantAccess(
decoded.userId,
request.app.tenantId
);
if (!hasAccess) {
throw new Error('No access to this tenant');
}
return { isValid: true, credentials: decoded };
};
Common mistakes? I’ve made most of them. Forgetting to set the search path. Not handling schema creation failures. Assuming all tenants want the same database indexes. The key is to expect things to fail and handle them gracefully.
One particular issue: what happens when a tenant needs a custom field? With schema-per-tenant, you can actually give them custom tables or columns. But this requires careful management. I usually offer a limited set of customizable fields through a metadata table rather than altering schema structure.
Scaling brings new challenges. When you have hundreds of tenants, running migrations across all of them takes time. I use a queue system to spread the load:
// src/services/migration-queue.ts
export class MigrationQueue {
async scheduleMigration(migrationName: string) {
// Get all tenants
const tenants = await this.getAllTenants();
// Add to queue with rate limiting
for (const tenant of tenants) {
await this.queue.add('run-migration', {
tenantId: tenant.id,
migrationName
}, {
// Process 10 tenants at a time
jobId: `${migrationName}-${tenant.id}`,
priority: 1
});
}
}
}
This approach lets me control how many migrations run simultaneously, preventing database overload.
What about tenant onboarding? The process needs to be smooth. When someone signs up, we create their schema, run base migrations, and seed initial data—all within a transaction so if anything fails, nothing is partially created.
Backup and recovery require special thought too. We need to backup each tenant’s schema individually. In a disaster recovery scenario, we might need to restore just one tenant without affecting others.
The beauty of this architecture is its flexibility. Need to move a high-volume tenant to their own database? Since they’re already in a separate schema, the move is relatively straightforward. Their data is already isolated.
I’ve used this pattern for applications serving from 10 to 10,000 tenants. The principles remain the same, though the implementation details evolve. Start with clean separation, add monitoring early, and always think about how each decision affects tenant isolation.
Remember, the goal isn’t just technical perfection. It’s building a system that instills confidence in your customers. They need to trust that their data is safe and separate. Every design decision should reinforce that trust.
What questions do you have about implementing this in your own projects? Have you encountered specific challenges with multi-tenant architectures? I’d love to hear about your experiences in the comments below. If you found this guide helpful, please share it with other developers who might be facing similar challenges.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva