next-cool-cache
Scenarios

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-456

UX: 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

  1. Multi-param tags: Use tenantId + resource ID patterns for isolation
  2. Scope by role: superadmin sees all, tenant-admin sees their tenant, member sees limited data
  3. Cross-tenant operations: Super admin invalidations should cascade properly
  4. Settings propagation: Tenant settings may need to invalidate member caches
  5. Audit logging: Cache by tenant and date for efficient invalidation

On this page