This content originally appeared on DEV Community and was authored by Yusuf Kareem
Here’s my portfolio website built and hosted on Framer thekareemyusuf.framer.website
. Have you ever paused to think about what the thekareemyusuf part of that URL signifies, or how a single service like Framer can power countless distinct websites—each with unique content, design, and configurations—all while running on the same underlying software infrastructure? This powerful and efficient approach is known as multi-tenancy.
In this example, thekareemyusuf
acts as a unique identifier for your specific “tenant” within the larger Framer application. It tells Framer’s system that when a request comes in for thekareemyusuf.framer.website
, it should serve your specific website content and configurations, keeping it entirely separate and isolated from other users’ websites (e.g., gift.framer.website
). This architectural principle dictates that a single instance of a software application serves multiple distinct client organizations or individual users, known as “tenants.” Each tenant operates as an independent entity within this shared application environment, accessing their specific data and configurations while remaining completely isolated from other tenants. This paradigm is fundamental to Software-as-a-Service (SaaS) applications, enabling providers like Framer to efficiently serve a vast customer base from a unified codebase and infrastructure.
The core promise of multi-tenancy is resource optimization. By sharing the underlying application instance, server resources, and often database infrastructure, SaaS providers can achieve significant cost savings and streamlined management compared to deploying separate application instances for each customer (single-tenancy).
Why Multi‑Tenancy?
Cost Efficiency: Sharing infrastructure (servers, databases, network) across multiple tenants drastically reduces hardware, licensing, and operational costs.
Simplified Management & Maintenance: Updates and patches are applied once to the single application instance, reducing development and maintenance overhead.
Scalability: Easier to scale one application instance than mange many separate deployments.
Faster Feature Delivery: Instant rollout of new features to all tenants, accelerating value delivery.
Operational Efficiency: Centralized logging, monitoring, and debugging streamline workflows.
Core Concepts in Multi‑Tenancy
Tenant Identification: The application must determine which tenant is making a request, routing it to the correct data and logic.
Data Isolation: Although resources are shared, each tenant’s data must remain strictly isolated for security, privacy, and compliance.
Multi‑Tenancy Architectural Models
1. Separate Databases per Tenant (Siloed Model)
This model is where each tenant has its own dedicated, isolated database instance.
Pros:
- Maximum data isolation and security.
- Simplified backup & restore per tenant.
- Tenant‑specific schema optimizations.
- Performance isolation.
Cons:
- High resource consumption.
- Increased management overhead.
- Complex deployments at scale.
Use Case: A small number of large enterprise tenants with stringent security or customization needs.
2. Separate Schemas per Tenant
Description: All tenants share the same database server, but each has its own logical schema.
Pros:
- Strong data isolation.
- More resource‑efficient than separate databases.
- Simplified management of a single server.
Cons:
- Complex schema migrations across many schemas.
- Dependence on database vendor support.
- Potential resource contention.
Use Case: Medium‑sized tenant base requiring good isolation without full silo overhead.
3. Shared Database, Shared Schema with Tenant ID (Discriminator Column)
Description: All tenants share one database and table set. Each table includes a tenant_id (or discriminator) column to identify row ownership.
Pros:
- Maximum resource efficiency.
- Easiest management and schema updates.
- High scalability.
Cons:
- Critical reliance on correct tenant_id filtering.
- Potential performance bottlenecks on large tables.
- Limited tenant‑specific customization.
- Complex per‑tenant backup/restore.
Use Case: Large number of small‑to‑medium tenants where cost and ease of management are paramount.
Key Implementation Considerations
Building a robust multi-tenant system requires meticulous attention to several key areas. While the architectural model dictates the high-level design, the following implementation considerations are crucial for ensuring security, performance, and scalability in your multi-tenant application.
1. Tenant Identification Strategies
This is the first and most critical step in processing any request. The application must reliably determine which tenant the request belongs to before it can access any data or apply tenant-specific logic.
-
Subdomain:
tenant1.yourdomain.com
This is a popular and user-friendly method where the tenant’s unique identifier is part of the URL’s subdomain. For implementation, this requires wildcard DNS configuration (e.g.,*.yourdomain.com
points to your application’s servers) and a mechanism within your application (e.g., middleware) to parse the hostname and extract the tenant ID. It’s great for branding but requires careful DNS management. -
Path Prefix:
/tenant1/dashboard
This approach embeds the tenant ID directly into the URL path. It’s simpler to set up than subdomain routing as it doesn’t require complex DNS configuration. Your application’s routing logic simply needs to extract the tenant ID from the first segment of the path. -
Custom HTTP Header:
X-Tenant-ID: tenant1
Primarily used in API-first architectures where a frontend application or another service communicates with the backend. The client includes a specific HTTP header with the tenant’s ID in every request. This is clean and decouples tenant identification from the URL structure but requires client-side consistency. - User Session/JWT: Store tenant ID post‑login Once a user successfully authenticates, their associated tenantId is retrieved from the user database and embedded into their session data or a JWT (JSON Web Token). For all subsequent requests, the server can then extract the tenantId from the session or decode the JWT, making it available without needing it in the URL or headers for every single call. This is a secure and common method for authenticated sessions.
2. Data Isolation Techniques (Shared Schema)
In a shared database/schema model, enforcing data isolation is paramount. The following techniques are used to prevent data leaks between tenants.
-
Query Scoping: Include
tenant_id
in every query. This is the core security mechanism. Every query that interacts with tenant-specific data must explicitly includetenant_id
. A single missing filter in a single query can expose a tenant’s data to another. This requires extreme discipline from developers. - ORM Integration: Global tenant filters/hooks.
- Database Triggers/Views: Supplementary integrity checks.
3. Tenant Provisioning
This is the automated process of onboarding a new tenant from start to finish. A well-designed provisioning system is crucial for scalable growth.
- Tenant Registration: Capture tenant details. This first step involves a user signing up and providing basic information about their organization (e.g., company name, contact info). This data is used to create a new tenant record in a central database.
- Database Setup: Create database/schema programmatically. If you’re using a separate database or schema model, this step involves running a script or a service to create a new database or schema for the new tenant. This process must be fully automated to avoid manual intervention as you scale.
- DNS Configuration: Automate subdomain record creation. For the subdomain strategy, the provisioning process must automatically create the necessary DNS A or CNAME records to point tenantX.yourdomain.com to your application’s load balancer or server. This is often done via APIs provided by DNS registrars or cloud providers.
4. Security Considerations
- Strict access control and tenant‑aware authorization. Your application’s authorization system must not only check if a user has permission to perform an action but also verify that they have permission to do so for their specific tenant. For example, a user from Tenant A should never be able to access a resource from Tenant B, even if they have the right role.
- Data encryption at rest and in transit. Encrypt tenant data both at rest (in the database) and in transit (via TLS/SSL). This is a standard security practice that is even more critical when multiple tenants’ data resides on the same physical disk.
- Input validation against injection attacks. Robustly validate all user inputs to prevent SQL injection and other attacks that could be used to bypass tenant ID filters and access other tenants’ data.
- Regular security audits and penetration testing.
5. Scalability & Performance
- Index
tenant_id
columns. - Efficient connection pooling.
- Multi‑tenant aware caching.
- Load balancing and resource throttling.
- Comprehensive monitoring and alerting.
Implementation Example: Shared Database, Shared Schema with Tenant ID
This tutorial will walk you through building a multi-tenant Node.js application from the ground up, using a shared database and a discriminator column for data isolation.
Step 1: Project Setup and Initialization
First, let’s create our project directory and initialize a new Node.js project.
mkdir multi-tenant-app
cd multi-tenant-app
npm init -y
Step 2: Installing Dependencies
We’ll need Express for our server, Mongoose for database modeling, JWT for authentication, and a few other utilities.
npm install express mongoose jsonwebtoken bcryptjs body-parser
Step 3: Folder Structure
Organize your project with a clear and scalable folder structure.
/
|-- controllers/
| |-- authController.js
| |-- productController.js
| `-- tenantController.js
|-- middleware/
| `-- authMiddleware.js
|-- models/
| |-- Product.js
| |-- Tenant.js
| `-- User.js
|-- routes/
| |-- authRoutes.js
| |-- productRoutes.js
| `-- tenantRoutes.js
|-- .gitignore
|-- app.js
|-- config.js
|-- db.js
`-- package.json
Step 4: Database Configuration
Create a config.js
file for your environment variables and a db.js
file to handle the MongoDB connection.
config.js
module.exports = {
PORT: process.env.PORT || 3000,
MONGO_URI: process.env.MONGO_URI || 'mongodb://localhost:27017/multi-tenant-db',
JWT_SECRET: process.env.JWT_SECRET || 'your-jwt-secret',
JWT_EXPIRES_IN: '1d'
};
db.js
const mongoose = require('mongoose');
const config = require('./config');
const connectDB = async () => {
try {
await mongoose.connect(config.MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log('MongoDB Connected...');
} catch (err) {
console.error(err.message);
process.exit(1);
}
};
module.exports = connectDB;
Step 5: Defining the Models
We need three models: Tenant
, User
, and Product
. The User
and Product
models will both contain a reference to the Tenant
.
models/Tenant.js
const mongoose = require('mongoose');
const TenantSchema = new mongoose.Schema({
name: { type: String, required: true },
subdomain: { type: String, required: true, unique: true }
}, { timestamps: true });
module.exports = mongoose.model('Tenant', TenantSchema);
models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const UserSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
tenant: { type: mongoose.Schema.Types.ObjectId, ref: 'Tenant', required: true }
}, { timestamps: true });
UserSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
UserSchema.methods.comparePassword = function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', UserSchema);
models/Product.js
const mongoose = require('mongoose');
const ProductSchema = new mongoose.Schema({
name: { type: String, required: true },
description: { type: String, required: true },
price: { type: Number, required: true },
tenant: { type: mongoose.Schema.Types.ObjectId, ref: 'Tenant', required: true }
}, { timestamps: true });
module.exports = mongoose.model('Product', ProductSchema);
Step 6: Tenant and User Management Controllers
Create controllers to handle the business logic for creating tenants and managing users.
controllers/tenantController.js
const Tenant = require('../models/Tenant');
exports.createTenant = async (req, res) => {
const { name, subdomain } = req.body;
try {
const tenant = new Tenant({ name, subdomain });
await tenant.save();
res.status(201).json({ message: 'Tenant created successfully', tenantId: tenant._id });
} catch (error) {
res.status(400).json({ message: 'Failed to create tenant', error: error.message });
}
};
controllers/authController.js
const User = require('../models/User');
const Tenant = require('../models/Tenant');
const jwt = require('jsonwebtoken');
const config = require('../config');
const generateToken = (id, tenantId) => {
return jwt.sign({ id, tenantId }, config.JWT_SECRET, { expiresIn: config.JWT_EXPIRES_IN });
};
exports.signup = async (req, res) => {
const { name, email, password } = req.body;
const tenantSubdomain = req.headers['x-tenant-id'];
if (!tenantSubdomain) {
return res.status(400).json({ message: 'x-tenant-id header is required' });
}
try {
const tenant = await Tenant.findOne({ subdomain: tenantSubdomain });
if (!tenant) {
return res.status(404).json({ message: 'Tenant not found' });
}
const user = new User({ name, email, password, tenant: tenant._id });
await user.save();
res.status(201).json({
message: 'User created successfully',
token: generateToken(user._id, tenant._id)
});
} catch (error) {
res.status(400).json({ message: 'Failed to create user', error: error.message });
}
};
exports.login = async (req, res) => {
const { email, password } = req.body;
const tenantSubdomain = req.headers['x-tenant-id'];
if (!tenantSubdomain) {
return res.status(400).json({ message: 'x-tenant-id header is required' });
}
try {
const tenant = await Tenant.findOne({ subdomain: tenantSubdomain });
if (!tenant) {
return res.status(404).json({ message: 'Tenant not found' });
}
const user = await User.findOne({ email, tenant: tenant._id });
if (user && (await user.comparePassword(password))) {
res.json({ token: generateToken(user._id, user.tenant) });
} else {
res.status(401).json({ message: 'Invalid credentials' });
}
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
};
Step 7: The Tenant Identification Middleware
This middleware is the core of our multi-tenancy logic. It inspects incoming requests, identifies the tenant, and attaches the tenantId
to the request object for downstream use.
middleware/authMiddleware.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const Tenant = require('../models/Tenant');
const config = require('../config.js');
const protect = async (req, res, next) => {
let token;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
try {
token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, config.JWT_SECRET);
req.user = await User.findById(decoded.id).select('-password');
const tenantSubdomain = req.headers['x-tenant-id'];
if (!tenantSubdomain) {
return res.status(400).json({ message: 'x-tenant-id is required' });
}
const tenant = await Tenant.findOne({ subdomain: tenantSubdomain });
if (!tenant) {
return res.status(404).json({ message: 'Tenant not found' });
}
req.tenantId = tenant._id;
if (!req.user || req.user.tenant.toString() !== req.tenantId.toString()) {
return res.status(401).json({ message: 'Not authorized for this tenant' });
}
next();
} catch (error) {
res.status(401).json({ message: 'Not authorized, token failed' });
}
} else {
res.status(401).json({ message: 'Not authorized, no token' });
}
};
module.exports = { protect };
Step 8: Product Controller with Data Isolation
All database queries in the productController
must be scoped to the tenantId
from the request object.
controllers/productController.js
const Product = require('../models/Product');
exports.createProduct = async (req, res) => {
const { name, description, price } = req.body;
try {
const product = new Product({ name, description, price, tenant: req.tenantId });
await product.save();
res.status(201).json(product);
} catch (error) {
res.status(400).json({ message: 'Failed to create product' });
}
};
exports.getProducts = async (req, res) => {
try {
const products = await Product.find({ tenant: req.tenantId });
res.json(products);
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
};
Step 9: Defining the Routes
Set up the API routes for tenants, authentication, and products.
routes/tenantRoutes.js
const express = require('express');
const router = express.Router();
const { createTenant } = require('../controllers/tenantController');
router.post('/', createTenant);
module.exports = router;
routes/authRoutes.js
const express = require('express');
const router = express.Router();
const { signup, login } = require('../controllers/authController');
router.post('/signup', signup);
router.post('/login', login);
module.exports = router;
routes/productRoutes.js
const express = require('express');
const router = express.Router();
const { createProduct, getProducts } = require('../controllers/productController');
const { protect } = require('../middleware/authMiddleware');
router.route('/').post(protect, createProduct).get(protect, getProducts);
module.exports = router;
Step 10: Bringing It All Together in app.js
Finally, assemble all the pieces in your main app.js
file.
const express = require('express');
const bodyParser = require('body-parser');
const connectDB = require('./db');
const config = require('./config');
// Import Routes
const tenantRoutes = require('./routes/tenantRoutes');
const authRoutes = require('./routes/authRoutes');
const productRoutes = require('./routes/productRoutes');
// Connect to Database
connectDB();
const app = express();
// Middleware
app.use(bodyParser.json());
// API Routes
app.use('/api/v1/tenants', tenantRoutes);
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/products', productRoutes);
app.get('/', (req, res) => {
res.send('Multi-Tenant API is running...');
});
app.listen(config.PORT, () => {
console.log(`Server running on port ${config.PORT}`);
});
Step 11: Running the Application
Start your server with:
node app.js
Your multi-tenant API is now live! You can use a tool like Postman or curl
to interact with it. Remember to always include the x-tenant-id
header when signing up, logging in, or accessing protected resources.
Challenges and Best Practices
- Design Complexity: Testing and operations become more sophisticated.
- Data Migration: Moving tenants between models is non‑trivial.
- Tenant Customization: Balance shared codebase with customization needs.
- Resource Contention: Handle “noisy neighbors” via rate limiting or QoS.
- Backup & Recovery: Plan for both global and per-tenant restores.
Conclusion
Multi‑tenancy is a powerful SaaS pattern, offering cost, operational, and scalability benefits. The choice of model, whether siloed databases, separate schemas, or shared schema with tenant IDs, depends on the trade-offs between isolation, customization, and resource efficiency. With the right design, implementation patterns, and best practices, you can build a secure and scalable multi-tenant application that serves diverse customers from a single codebase.
This content originally appeared on DEV Community and was authored by Yusuf Kareem