Multi-tenant App
Implementing per-tenant cache isolation in a multi-tenant SaaS application
Multi-tenant App Scenario
Learn how to implement caching for a multi-tenant SaaS application where data must be isolated between tenants while administrators can manage across tenants.
The Challenge
Multi-tenant applications have unique caching concerns:
- Tenant isolation: Data from one tenant must never leak to another
- Per-tenant caching: Each tenant has their own cache namespace
- Cross-tenant admin: Super admins need to view data across tenants
- Tenant settings: Configuration that affects all members
- Multi-param tags: Often need to identify by both tenant and resource
Schema Design
// lib/cache.ts
import { createCache } from 'next-cool-cache';
const schema = {
tenants: {
list: {},
byId: { _params: ['tenantId'] as const },
bySlug: { _params: ['slug'] as const },
settings: {
byTenantId: { _params: ['tenantId'] as const },
},
billing: {
byTenantId: { _params: ['tenantId'] as const },
},
},
projects: {
list: {},
byId: { _params: ['projectId'] as const },
byTenant: { _params: ['tenantId'] as const },
byTenantAndSlug: { _params: ['tenantId', 'slug'] as const },
},
members: {
byTenant: { _params: ['tenantId'] as const },
byTenantAndUser: { _params: ['tenantId', 'userId'] as const },
byUser: { _params: ['userId'] as const },
},
invitations: {
byTenant: { _params: ['tenantId'] as const },
byId: { _params: ['invitationId'] as const },
},
audit: {
byTenant: { _params: ['tenantId'] as const },
byTenantAndDate: { _params: ['tenantId', 'date'] as const },
},
} as const;
const scopes = ['superadmin', 'tenant-admin', 'member'] as const;
export const cache = createCache(schema, scopes);Implementation Patterns
Pattern 1: Tenant Dashboard
Each tenant has their own isolated view:
// app/[tenant]/dashboard/page.tsx
import { cache } from '@/lib/cache';
import { getTenantFromSlug, requireTenantAccess } from '@/lib/tenant';
async function getTenantOverview(tenantId: string) {
'use cache: private';
cache['tenant-admin'].tenants.byId.cacheTag({ tenantId });
return db.tenants.findUnique({
where: { id: tenantId },
include: {
_count: {
select: {
projects: true,
members: true,
},
},
},
});
}
async function getTenantProjects(tenantId: string) {
'use cache: private';
cache['tenant-admin'].projects.byTenant.cacheTag({ tenantId });
return db.projects.findMany({
where: { tenantId },
orderBy: { updatedAt: 'desc' },
take: 10,
});
}
async function getTenantMembers(tenantId: string) {
'use cache: private';
cache['tenant-admin'].members.byTenant.cacheTag({ tenantId });
return db.members.findMany({
where: { tenantId },
include: { user: true },
orderBy: { createdAt: 'desc' },
});
}
export default async function TenantDashboard({
params,
}: {
params: { tenant: string };
}) {
const tenant = await getTenantFromSlug(params.tenant);
await requireTenantAccess(tenant.id);
const [overview, projects, members] = await Promise.all([
getTenantOverview(tenant.id),
getTenantProjects(tenant.id),
getTenantMembers(tenant.id),
]);
return (
<TenantLayout tenant={tenant}>
<OverviewStats overview={overview} />
<RecentProjects projects={projects} />
<TeamMembers members={members} />
</TenantLayout>
);
}Pattern 2: Project Access with Multi-Param Tags
// app/[tenant]/projects/[slug]/page.tsx
import { cache } from '@/lib/cache';
async function getProject(tenantId: string, slug: string) {
'use cache: private';
cache.member.projects.byTenantAndSlug.cacheTag({ tenantId, slug });
return db.projects.findUnique({
where: {
tenantId_slug: { tenantId, slug },
},
include: {
members: { include: { user: true } },
settings: true,
},
});
}
export default async function ProjectPage({
params,
}: {
params: { tenant: string; slug: string };
}) {
const tenant = await getTenantFromSlug(params.tenant);
await requireTenantAccess(tenant.id);
const project = await getProject(tenant.id, params.slug);
if (!project) notFound();
return (
<ProjectLayout project={project}>
<ProjectContent project={project} />
</ProjectLayout>
);
}Pattern 3: Member Management
// app/actions/members.ts
'use server';
import { cache } from '@/lib/cache';
import { requireTenantAdmin } from '@/lib/tenant';
export async function inviteMember(tenantId: string, email: string, role: string) {
await requireTenantAdmin(tenantId);
const invitation = await db.invitations.create({
data: {
tenantId,
email,
role,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// Update invitation list
cache['tenant-admin'].invitations.byTenant.updateTag({ tenantId });
return invitation;
}
export async function acceptInvitation(invitationId: string, userId: string) {
const invitation = await db.invitations.findUnique({
where: { id: invitationId },
});
if (!invitation || invitation.expiresAt < new Date()) {
throw new Error('Invalid or expired invitation');
}
// Create membership
await db.members.create({
data: {
tenantId: invitation.tenantId,
userId,
role: invitation.role,
},
});
// Delete invitation
await db.invitations.delete({ where: { id: invitationId } });
// Update caches
cache['tenant-admin'].members.byTenant.updateTag({ tenantId: invitation.tenantId });
cache['tenant-admin'].invitations.byTenant.updateTag({ tenantId: invitation.tenantId });
cache.member.members.byUser.updateTag({ userId });
// Update tenant overview
cache['tenant-admin'].tenants.byId.revalidateTag({ tenantId: invitation.tenantId });
}
export async function removeMember(tenantId: string, userId: string) {
await requireTenantAdmin(tenantId);
await db.members.delete({
where: {
tenantId_userId: { tenantId, userId },
},
});
// Update caches
cache['tenant-admin'].members.byTenant.updateTag({ tenantId });
cache['tenant-admin'].members.byTenantAndUser.updateTag({ tenantId, userId });
cache.member.members.byUser.updateTag({ userId });
cache['tenant-admin'].tenants.byId.revalidateTag({ tenantId });
}
export async function updateMemberRole(tenantId: string, userId: string, newRole: string) {
await requireTenantAdmin(tenantId);
await db.members.update({
where: {
tenantId_userId: { tenantId, userId },
},
data: { role: newRole },
});
// Update member caches
cache['tenant-admin'].members.byTenant.updateTag({ tenantId });
cache['tenant-admin'].members.byTenantAndUser.updateTag({ tenantId, userId });
}Pattern 4: Tenant Settings
// app/[tenant]/settings/page.tsx
import { cache } from '@/lib/cache';
async function getTenantSettings(tenantId: string) {
'use cache: private';
cache['tenant-admin'].tenants.settings.byTenantId.cacheTag({ tenantId });
return db.tenantSettings.findUnique({
where: { tenantId },
});
}
// app/actions/settings.ts
'use server';
export async function updateTenantSettings(tenantId: string, data: SettingsData) {
await requireTenantAdmin(tenantId);
const settings = await db.tenantSettings.update({
where: { tenantId },
data,
});
// Settings visible to tenant admin immediately
cache['tenant-admin'].tenants.settings.byTenantId.updateTag({ tenantId });
// If settings affect all members (e.g., feature flags), update member scope too
if (data.features) {
cache.member.tenants.settings.byTenantId.revalidateTag({ tenantId });
}
return settings;
}Pattern 5: Super Admin Cross-Tenant View
// app/admin/tenants/page.tsx
import { cache } from '@/lib/cache';
import { requireSuperAdmin } from '@/lib/auth';
async function getAllTenants() {
'use cache: remote';
cache.superadmin.tenants.list.cacheTag();
return db.tenants.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { members: true, projects: true },
},
billing: true,
},
});
}
export default async function AdminTenantsPage() {
await requireSuperAdmin();
const tenants = await getAllTenants();
return (
<AdminLayout>
<TenantsList tenants={tenants} />
</AdminLayout>
);
}
// app/admin/tenants/[id]/page.tsx
async function getAdminTenantView(tenantId: string) {
'use cache: remote';
cache.superadmin.tenants.byId.cacheTag({ tenantId });
return db.tenants.findUnique({
where: { id: tenantId },
include: {
members: { include: { user: true } },
projects: true,
billing: true,
settings: true,
},
});
}Pattern 6: Cross-Tenant Operations
Super admin operations that affect multiple tenants:
// app/actions/admin.ts
'use server';
import { cache } from '@/lib/cache';
import { requireSuperAdmin } from '@/lib/auth';
export async function suspendTenant(tenantId: string, reason: string) {
await requireSuperAdmin();
await db.tenants.update({
where: { id: tenantId },
data: {
status: 'suspended',
suspensionReason: reason,
},
});
// Update super admin view
cache.superadmin.tenants.byId.updateTag({ tenantId });
cache.superadmin.tenants.list.revalidateTag();
// Invalidate all tenant-scoped caches
cache['tenant-admin'].tenants.byId.updateTag({ tenantId });
cache['tenant-admin'].projects.byTenant.updateTag({ tenantId });
cache.member.projects.byTenant.updateTag({ tenantId });
}
export async function deleteTenant(tenantId: string) {
await requireSuperAdmin();
// Delete all tenant data
await db.tenants.delete({ where: { id: tenantId } });
// Invalidate all caches for this tenant across all scopes
cache.tenants.byId.revalidateTag({ tenantId });
cache.projects.byTenant.revalidateTag({ tenantId });
cache.members.byTenant.revalidateTag({ tenantId });
// Update super admin list
cache.superadmin.tenants.list.updateTag();
}
export async function migrateTenantsToNewPlan(oldPlanId: string, newPlanId: string) {
await requireSuperAdmin();
const affectedTenants = await db.tenants.findMany({
where: { planId: oldPlanId },
});
await db.tenants.updateMany({
where: { planId: oldPlanId },
data: { planId: newPlanId },
});
// Invalidate billing for all affected tenants
for (const tenant of affectedTenants) {
cache['tenant-admin'].tenants.billing.byTenantId.revalidateTag({
tenantId: tenant.id,
});
}
// Update super admin tenant list
cache.superadmin.tenants.list.revalidateTag();
}Pattern 7: Audit Log
// lib/audit.ts
import { cache } from '@/lib/cache';
async function getAuditLog(tenantId: string, date?: string) {
'use cache: private';
if (date) {
cache['tenant-admin'].audit.byTenantAndDate.cacheTag({ tenantId, date });
} else {
cache['tenant-admin'].audit.byTenant.cacheTag({ tenantId });
}
return db.auditLog.findMany({
where: {
tenantId,
...(date && {
createdAt: {
gte: new Date(date),
lt: new Date(new Date(date).getTime() + 24 * 60 * 60 * 1000),
},
}),
},
orderBy: { createdAt: 'desc' },
take: 100,
include: { user: true },
});
}
// When logging audit events
export async function logAuditEvent(
tenantId: string,
userId: string,
action: string,
details: object
) {
await db.auditLog.create({
data: { tenantId, userId, action, details },
});
// Invalidate today's audit cache
const today = new Date().toISOString().split('T')[0];
cache['tenant-admin'].audit.byTenantAndDate.revalidateTag({ tenantId, date: today });
cache['tenant-admin'].audit.byTenant.revalidateTag({ tenantId });
}Tenant Context Helper
Create a helper to ensure tenant-scoped caching:
// lib/tenant-cache.ts
import { cache } from '@/lib/cache';
export function getTenantCache(tenantId: string) {
return {
projects: {
cacheTag: () => cache.member.projects.byTenant.cacheTag({ tenantId }),
revalidate: () => cache.member.projects.byTenant.revalidateTag({ tenantId }),
update: () => cache.member.projects.byTenant.updateTag({ tenantId }),
},
members: {
cacheTag: () => cache.member.members.byTenant.cacheTag({ tenantId }),
revalidate: () => cache.member.members.byTenant.revalidateTag({ tenantId }),
update: () => cache.member.members.byTenant.updateTag({ tenantId }),
},
// ... more helpers
};
}
// Usage
const tenantCache = getTenantCache(currentTenant.id);
tenantCache.projects.cacheTag();Frequently Asked Questions
How do I update tenant settings and have all members see them immediately?
Use updateTag() for tenant-admin scope (immediate) and revalidateTag() for member scope (SWR).
async function updateTenantSettings(tenantId: string, settings: Settings) {
await db.tenantSettings.update({ where: { tenantId }, data: settings });
// Tenant admin sees immediately
cache['tenant-admin'].tenants.settings.byTenantId.updateTag({ tenantId });
// → updateTag('tenant-admin/tenants/settings/byTenantId:tenant-123')
// Members get SWR (settings changes can tolerate brief staleness)
cache.member.tenants.settings.byTenantId.revalidateTag({ tenantId });
// → revalidateTag('member/tenants/settings/byTenantId:tenant-123', 'max')
}UX: The admin making the change sees it instantly. Other team members see the old settings briefly, then they auto-update in the background. This avoids loading spinners for regular members while giving admins immediate feedback.
How do I ensure one tenant's data never leaks to another tenant's cache?
Always include tenantId in your cache tags - the tag structure guarantees isolation.
// Caching tenant-specific data - ALWAYS include tenantId
async function getTenantProjects(tenantId: string) {
'use cache: private';
cache.member.projects.byTenant.cacheTag({ tenantId });
// → cacheTag('member/projects/byTenant:tenant-123', ...)
return db.projects.findMany({ where: { tenantId } });
}
// Invalidation is also tenant-specific
cache.member.projects.byTenant.revalidateTag({ tenantId: 'tenant-123' });
// → revalidateTag('member/projects/byTenant:tenant-123', 'max')
// This ONLY affects tenant-123, never tenant-456UX: Complete data isolation is guaranteed by the cache tag structure. Tenant A's data is cached under tenant-123 tags, Tenant B's under tenant-456. They can never overlap or leak because the tags are completely different strings.
How do I invalidate all data for a tenant when they're suspended?
Use branch-level invalidation to clear all tenant-scoped caches.
async function suspendTenant(tenantId: string) {
await db.tenants.update({ where: { id: tenantId }, data: { status: 'suspended' } });
// Invalidate everything for this tenant across all scopes
cache['tenant-admin'].tenants.byId.updateTag({ tenantId });
// → updateTag('tenant-admin/tenants/byId:tenant-123')
cache['tenant-admin'].projects.byTenant.updateTag({ tenantId });
// → updateTag('tenant-admin/projects/byTenant:tenant-123')
cache.member.projects.byTenant.updateTag({ tenantId });
// → updateTag('member/projects/byTenant:tenant-123')
// Update superadmin's view
cache.superadmin.tenants.list.revalidateTag();
// → revalidateTag('superadmin/tenants/list', 'max')
}UX: All pages for the suspended tenant immediately show the suspended state (or redirect to an error page). The superadmin's tenant list updates in the background to show the new status.
How do I update a project and have team members see it immediately?
Use updateTag() with both tenantId and project identifiers for immediate updates.
async function updateProject(tenantId: string, projectId: string, data: ProjectData) {
await db.projects.update({ where: { id: projectId }, data });
// Update the specific project (by tenant + slug for URL-based lookups)
cache.member.projects.byTenantAndSlug.updateTag({ tenantId, slug: data.slug });
// → updateTag('member/projects/byTenantAndSlug:tenant-123:project-slug')
// Update the project list for this tenant
cache.member.projects.byTenant.revalidateTag({ tenantId });
// → revalidateTag('member/projects/byTenant:tenant-123', 'max')
// Also update tenant-admin view
cache['tenant-admin'].projects.byTenant.updateTag({ tenantId });
// → updateTag('tenant-admin/projects/byTenant:tenant-123')
}UX: All team members see the updated project immediately on refresh. The project list in the sidebar updates in the background (SWR), avoiding disruptive loading states during navigation.
How do I handle cross-tenant admin views efficiently?
Use the superadmin scope with appropriate freshness - aggregate data can use SWR.
// Super admin viewing all tenants - can tolerate brief staleness
async function getAllTenants() {
'use cache: remote';
cache.superadmin.tenants.list.cacheTag();
// → cacheTag('superadmin/tenants/list', 'superadmin/tenants', 'superadmin', 'tenants/list', 'tenants')
return db.tenants.findMany({ include: { _count: { select: { members: true } } } });
}
// When a tenant changes, revalidate the superadmin view
cache.superadmin.tenants.list.revalidateTag();
// → revalidateTag('superadmin/tenants/list', 'max')
// For specific tenant details, superadmin can get fresh data
cache.superadmin.tenants.byId.updateTag({ tenantId });
// → updateTag('superadmin/tenants/byId:tenant-123')UX: The superadmin dashboard shows slightly delayed aggregate data (total tenants, member counts), which is fine for overview screens. When drilling into a specific tenant, they get fresh data immediately.
How do I cache audit logs without performance issues?
Cache by tenant AND date for efficient, granular invalidation.
// Fetching audit logs - cache by date for efficiency
async function getAuditLog(tenantId: string, date?: string) {
'use cache: private';
if (date) {
cache['tenant-admin'].audit.byTenantAndDate.cacheTag({ tenantId, date });
// → cacheTag('tenant-admin/audit/byTenantAndDate:tenant-123:2024-01-15', ...)
} else {
cache['tenant-admin'].audit.byTenant.cacheTag({ tenantId });
// → cacheTag('tenant-admin/audit/byTenant:tenant-123', ...)
}
return db.auditLog.findMany({ where: { tenantId, ...(date && { date }) } });
}
// When logging a new event, only invalidate today's cache
async function logAuditEvent(tenantId: string, event: AuditEvent) {
await db.auditLog.create({ data: { tenantId, ...event } });
const today = new Date().toISOString().split('T')[0];
cache['tenant-admin'].audit.byTenantAndDate.revalidateTag({ tenantId, date: today });
// → revalidateTag('tenant-admin/audit/byTenantAndDate:tenant-123:2024-01-15', 'max')
// Also invalidate the general audit log view
cache['tenant-admin'].audit.byTenant.revalidateTag({ tenantId });
// → revalidateTag('tenant-admin/audit/byTenant:tenant-123', 'max')
}UX: Historical audit logs (yesterday, last week) load instantly from cache and never need invalidation. Only today's log refreshes when new events are added. This keeps the audit log performant even with millions of entries.
Key Takeaways
- Multi-param tags: Use
tenantId+ resource ID patterns for isolation - Scope by role:
superadminsees all,tenant-adminsees their tenant,membersees limited data - Cross-tenant operations: Super admin invalidations should cascade properly
- Settings propagation: Tenant settings may need to invalidate member caches
- Audit logging: Cache by tenant and date for efficient invalidation