Multi-Tenant Implementation Checklist
MULTI_TENANT_IMPLEMENTATION_CHECKLIST.md
Multi-Tenant Implementation Checklist
Use this checklist when adding multi-tenant support to new features, models, or API endpoints.
Table of Contents
- Adding a New Tenant-Scoped Model
- Creating a New API Endpoint
- Adding a New Page/Route
- Testing Checklist
- Code Review Checklist
- Deployment Checklist
Adding a New Tenant-Scoped Model
Step 1: Define Model Interface
// models/MyModel.ts
import mongoose, { Document, Schema, Types } from 'mongoose';
export interface IMyModel extends Document {
// ✅ REQUIRED: Add tenantId field
tenantId?: Types.ObjectId;
// Your model fields
name: string;
description?: string;
status: 'active' | 'inactive';
// Timestamps
createdAt: Date;
updatedAt: Date;
}
Checklist:
- Interface includes
tenantId?: Types.ObjectId - Interface extends
Document - All required fields are marked as such
- Includes timestamps if needed
Step 2: Create Model Schema
const MyModelSchema = new Schema<IMyModel>(
{
// ✅ REQUIRED: Add tenantId field with index
tenantId: {
type: Schema.Types.ObjectId,
ref: 'Tenant',
index: true, // Critical for performance!
},
// Your fields
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
},
description: {
type: String,
trim: true,
},
status: {
type: String,
enum: ['active', 'inactive'],
default: 'active',
},
},
{
timestamps: true,
}
);
Checklist:
-
tenantIdfield added to schema -
tenantIdis typeSchema.Types.ObjectId -
tenantIdreferences'Tenant' -
tenantIdhasindex: true - Timestamps enabled with
{ timestamps: true }
Step 3: Add Compound Indexes
// ✅ REQUIRED: Add compound indexes with tenantId
MyModelSchema.index({ tenantId: 1, name: 1 });
MyModelSchema.index({ tenantId: 1, status: 1 });
MyModelSchema.index({ tenantId: 1, createdAt: -1 });
// For unique fields, make them tenant-scoped
MyModelSchema.index(
{ tenantId: 1, email: 1 },
{ unique: true, sparse: true }
);
Checklist:
- At least 3 compound indexes with tenantId
- Indexes cover common query patterns
- Unique constraints are tenant-scoped
- Indexes use
{ sparse: true }when tenantId is optional
Common Index Patterns:
-
{ tenantId: 1, createdAt: -1 }- for sorting by date -
{ tenantId: 1, status: 1 }- for filtering by status -
{ tenantId: 1, name: 1 }- for name lookups/sorting
Step 4: Export Model
export default mongoose.models.MyModel ||
mongoose.model<IMyModel>('MyModel', MyModelSchema);
Checklist:
- Uses
mongoose.models.MyModelpattern (prevents re-compilation) - Model name matches collection name convention
- Type parameter includes interface
Step 5: Update Model Index
// models/index.ts
// Add to appropriate section based on dependencies
export { default as MyModel } from './MyModel';
Checklist:
- Model exported from
models/index.ts - Placed in correct order based on dependencies
- JSDoc comment added if needed
Step 6: Add to Tenant Deletion Script
// scripts/delete-tenant.ts
const TENANT_SCOPED_COLLECTIONS = [
// ... existing collections
// Add your model
{ model: MyModel, name: 'mymodels' },
];
Checklist:
- Model added to deletion script
- Placed in correct order (dependent records first)
- Collection name matches MongoDB collection name
Creating a New API Endpoint
Step 1: Create Route File
// app/api/my-resource/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifySession } from '@/app/lib/dal';
import { unauthorizedResponse, requirePermission } from '@/app/lib/auth-helpers';
import { getTenantContext } from '@/lib/tenant';
import { Types } from 'mongoose';
import connectDB from '@/lib/mongodb';
import MyModel from '@/models/MyModel';
Checklist:
- All required imports included
-
getTenantContextimported - Model imported
- Auth helpers imported
Step 2: Implement GET Handler
export async function GET(request: NextRequest) {
// ✅ STEP 1: Verify authentication
const session = await verifySession();
if (!session) {
return unauthorizedResponse();
}
// ✅ STEP 2: Check permissions (if applicable)
const permissionCheck = await requirePermission(
session,
'my-resource',
'read'
);
if (permissionCheck) {
return permissionCheck;
}
try {
await connectDB();
// ✅ STEP 3: Get tenant context
const tenantContext = await getTenantContext();
const tenantId = session.tenantId || tenantContext.tenantId;
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant not found' },
{ status: 404 }
);
}
// ✅ STEP 4: Build tenant-scoped query
const query: any = {
tenantId: new Types.ObjectId(tenantId)
};
// Add additional filters from query params
const searchParams = request.nextUrl.searchParams;
if (searchParams.get('status')) {
query.status = searchParams.get('status');
}
// ✅ STEP 5: Execute query
const results = await MyModel.find(query)
.sort({ createdAt: -1 })
.limit(100);
return NextResponse.json({
success: true,
data: results
});
} catch (error: any) {
console.error('Error fetching resources:', error);
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
);
}
}
Checklist:
- Session verification implemented
- Permission check implemented (if needed)
- Tenant context retrieved
- Tenant ID validation included
- Query uses
Types.ObjectId()wrapper - Query includes
tenantIdfilter - Error handling implemented
- Results limited (pagination)
- Response format consistent
Step 3: Implement POST Handler
export async function POST(request: NextRequest) {
const session = await verifySession();
if (!session) {
return unauthorizedResponse();
}
const permissionCheck = await requirePermission(
session,
'my-resource',
'create'
);
if (permissionCheck) {
return permissionCheck;
}
try {
await connectDB();
const tenantContext = await getTenantContext();
const tenantId = session.tenantId || tenantContext.tenantId;
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant not found' },
{ status: 404 }
);
}
const body = await request.json();
// ✅ REQUIRED: Ensure tenantId is set
const data = {
...body,
tenantId: new Types.ObjectId(tenantId),
createdBy: session.userId // Optional: track creator
};
// Validate data (if needed)
if (!data.name) {
return NextResponse.json(
{ error: 'Name is required' },
{ status: 400 }
);
}
const document = await MyModel.create(data);
// Optional: Create audit log
await createAuditLog({
action: 'create',
resource: 'my-resource',
resourceId: document._id.toString(),
userId: session.userId,
tenantId,
details: { name: document.name }
});
return NextResponse.json({
success: true,
data: document
}, { status: 201 });
} catch (error: any) {
console.error('Error creating resource:', error);
return NextResponse.json(
{ success: false, error: error.message },
{ status: 500 }
);
}
}
Checklist:
- Session and permission checks
- Tenant context retrieved
-
tenantIdset on new document - Data validation implemented
- Error handling implemented
- Audit log created (if applicable)
- Returns 201 status on success
Step 4: Implement PUT/PATCH Handler
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await verifySession();
if (!session) {
return unauthorizedResponse();
}
const permissionCheck = await requirePermission(
session,
'my-resource',
'update'
);
if (permissionCheck) {
return permissionCheck;
}
try {
await connectDB();
const { id } = await params;
const tenantContext = await getTenantContext();
const tenantId = session.tenantId || tenantContext.tenantId;
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant not found' },
{ status: 404 }
);
}
// ✅ REQUIRED: Query with tenantId to prevent cross-tenant access
const document = await MyModel.findOne({
_id: id,
tenantId: new Types.ObjectId(tenantId)
});
if (!document) {
return NextResponse.json(
{ error: 'Resource not found' },
{ status: 404 }
);
}
const body = await request.json();
// Update fields (excluding tenantId!)
const allowedFields = ['name', 'description', 'status'];
allowedFields.forEach(field => {
if (body[field] !== undefined) {
document[field] = body[field];
}
});
await document.save();
return NextResponse.json({
success: true,
data: document
});
} catch (error: any) {
console.error('Error updating resource:', error);
return NextResponse.json(
{ success: false, error: error.message },
{ status: 500 }
);
}
}
Checklist:
- Query includes
tenantIdfilter - Document existence check
-
tenantIdis NOT updated from request body - Only allowed fields are updated
- Error handling implemented
Step 5: Implement DELETE Handler
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await verifySession();
if (!session) {
return unauthorizedResponse();
}
const permissionCheck = await requirePermission(
session,
'my-resource',
'delete'
);
if (permissionCheck) {
return permissionCheck;
}
try {
await connectDB();
const { id } = await params;
const tenantContext = await getTenantContext();
const tenantId = session.tenantId || tenantContext.tenantId;
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant not found' },
{ status: 404 }
);
}
// ✅ REQUIRED: Delete with tenantId filter
const result = await MyModel.deleteOne({
_id: id,
tenantId: new Types.ObjectId(tenantId)
});
if (result.deletedCount === 0) {
return NextResponse.json(
{ error: 'Resource not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
message: 'Resource deleted successfully'
});
} catch (error: any) {
console.error('Error deleting resource:', error);
return NextResponse.json(
{ success: false, error: error.message },
{ status: 500 }
);
}
}
Checklist:
- Delete query includes
tenantIdfilter - Checks if document was actually deleted
- Error handling implemented
- Returns appropriate status code
Step 6: Handle Relationships with Populate
// When populating relationships
const populateOptions: any = {
path: 'relatedModel',
select: 'name description'
};
// ✅ REQUIRED: Add tenant filter to populate
if (tenantId) {
populateOptions.match = {
tenantId: new Types.ObjectId(tenantId)
};
}
const results = await MyModel.find(query)
.populate(populateOptions);
Checklist:
- Populate includes
matchwithtenantId - All populated paths are tenant-filtered
- Select only needed fields
Adding a New Page/Route
Step 1: Server Component with Auth
// app/(app)/my-resource/page.tsx
import { redirect } from 'next/navigation';
import { verifySession } from '@/app/lib/dal';
import { getTenantContext } from '@/lib/tenant';
import MyResourcePageClient from '@/components/MyResourcePageClient';
export default async function MyResourcePage() {
// ✅ STEP 1: Verify authentication
const session = await verifySession();
if (!session) {
redirect('/login');
}
// ✅ STEP 2: Get tenant context
const tenantContext = await getTenantContext();
if (!tenantContext.tenant) {
redirect('/tenant-not-found');
}
// ✅ STEP 3: Pass tenant info to client component
return (
<MyResourcePageClient
tenantId={tenantContext.tenantId}
tenant={tenantContext.tenant}
/>
);
}
Checklist:
- Session verification implemented
- Redirects to
/loginif not authenticated - Tenant context retrieved
- Redirects to
/tenant-not-foundif no tenant - Tenant info passed to client component
Step 2: Client Component
// components/MyResourcePageClient.tsx
'use client';
import { useEffect, useState } from 'react';
interface Props {
tenantId: string | null;
tenant: any;
}
export default function MyResourcePageClient({ tenantId, tenant }: Props) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const response = await fetch('/api/my-resource');
const result = await response.json();
if (result.success) {
setData(result.data);
}
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
// Render component...
}
Checklist:
- Component marked with
'use client' - Tenant props typed correctly
- API calls use relative paths
- Loading states handled
- Error states handled
Testing Checklist
Unit Tests
// __tests__/api/my-resource.test.ts
describe('GET /api/my-resource', () => {
it('requires authentication', async () => {
const response = await fetch('/api/my-resource');
expect(response.status).toBe(401);
});
it('returns only tenant-scoped data', async () => {
const tenantA = await createTestTenant('tenant-a');
const tenantB = await createTestTenant('tenant-b');
await createTestData({ tenantId: tenantA._id, name: 'A' });
await createTestData({ tenantId: tenantB._id, name: 'B' });
const sessionA = await loginAsTenant(tenantA);
const response = await fetch('/api/my-resource', {
headers: { Cookie: `session=${sessionA}` }
});
const data = await response.json();
// Should only see tenant A data
expect(data.data).toHaveLength(1);
expect(data.data[0].name).toBe('A');
});
it('prevents cross-tenant access', async () => {
const tenantA = await createTestTenant('tenant-a');
const tenantB = await createTestTenant('tenant-b');
const docB = await createTestData({
tenantId: tenantB._id,
name: 'B'
});
const sessionA = await loginAsTenant(tenantA);
// Try to access tenant B's data with tenant A session
const response = await fetch(`/api/my-resource/${docB._id}`, {
headers: { Cookie: `session=${sessionA}` }
});
expect(response.status).toBe(404);
});
});
Test Checklist:
- Authentication tests
- Tenant isolation tests
- Cross-tenant access prevention tests
- CRUD operation tests
- Permission tests
- Error handling tests
Manual Testing
Checklist:
- Create data in tenant A
- Login to tenant B
- Verify tenant B cannot see tenant A data
- Try to access tenant A data via direct URL
- Verify proper error messages
- Test with expired subscription
- Test with suspended tenant
Code Review Checklist
Model Review
-
tenantIdfield included in interface -
tenantIdfield in schema with index - Compound indexes with
tenantId - Unique constraints are tenant-scoped
- Model exported correctly
- Added to deletion script
API Review
- Session verification present
- Permission checks implemented
- Tenant context retrieved
- All queries filter by
tenantId -
Types.ObjectId()wrapper used - Create operations set
tenantId - Update operations don't modify
tenantId - Delete operations filter by
tenantId - Populate includes tenant filter
- Error handling comprehensive
- Audit logging implemented (if applicable)
Frontend Review
- Server components verify auth
- Tenant context passed to client
- API calls don't expose
tenantIdin URL - Error states handled
- Loading states handled
Security Review
- No
tenantIdin request body is trusted - All queries use
Types.ObjectId() - Session
tenantIdmatches context - No hardcoded tenant IDs
- No tenant bypass routes
- Proper error messages (no data leakage)
Deployment Checklist
Pre-Deployment
- All tests pass
- Code reviewed and approved
- Database indexes created
- Migration scripts ready (if needed)
- Environment variables configured
- Backup created
Deployment
- Run migrations (if any)
- Deploy application
- Verify indexes created
- Test on staging environment
- Monitor error logs
Post-Deployment
- Verify existing tenants not affected
- Create test tenant and verify functionality
- Monitor performance metrics
- Check error rates
- Verify subscription checks working
Common Mistakes to Avoid
❌ DON'T: Trust tenantId from Request Body
// ❌ BAD
const body = await request.json();
const document = await MyModel.create(body); // body.tenantId could be manipulated!
// ✅ GOOD
const body = await request.json();
const tenantId = await getTenantId();
const document = await MyModel.create({
...body,
tenantId: new Types.ObjectId(tenantId)
});
❌ DON'T: Forget Types.ObjectId() Wrapper
// ❌ BAD
const query = { tenantId: tenantId }; // Could be string, vulnerable to injection
// ✅ GOOD
const query = { tenantId: new Types.ObjectId(tenantId) };
❌ DON'T: Use Global Unique Indexes
// ❌ BAD - Email unique across all tenants
Schema.index({ email: 1 }, { unique: true });
// ✅ GOOD - Email unique per tenant
Schema.index({ tenantId: 1, email: 1 }, { unique: true, sparse: true });
❌ DON'T: Forget Populate Filters
// ❌ BAD - Could populate cross-tenant data
const visits = await Visit.find({ tenantId })
.populate('patient');
// ✅ GOOD - Populate with tenant filter
const visits = await Visit.find({ tenantId })
.populate({
path: 'patient',
match: { tenantId: new Types.ObjectId(tenantId) }
});
❌ DON'T: Allow tenantId Updates
// ❌ BAD - Allows moving documents between tenants
const body = await request.json();
document.tenantId = body.tenantId; // Security vulnerability!
await document.save();
// ✅ GOOD - Exclude tenantId from updates
const allowedFields = ['name', 'description', 'status'];
allowedFields.forEach(field => {
if (body[field] !== undefined) {
document[field] = body[field];
}
});
await document.save();
❌ DON'T: Skip Tenant Context Validation
// ❌ BAD - Assumes tenantId exists
const tenantId = (await getTenantContext()).tenantId;
const results = await MyModel.find({ tenantId }); // tenantId could be null!
// ✅ GOOD - Validate tenant context
const tenantContext = await getTenantContext();
const tenantId = tenantContext.tenantId;
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant not found' },
{ status: 404 }
);
}
const results = await MyModel.find({
tenantId: new Types.ObjectId(tenantId)
});
Quick Reference Commands
# Run tests
npm test
# Run specific test file
npm test -- my-resource.test.ts
# Check linting
npm run lint
# Format code
npm run format
# Create new tenant (for testing)
npm run tenant:onboard
# Delete tenant (cleanup)
npm run tenant:delete
# Check database indexes
mongosh
use clinic-db
db.mymodels.getIndexes()
Additional Resources
Checklist Version: 1.0 Last Updated: January 2026