Tenant Context Migration Guide
Problem: 887 Redundant Tenant Lookups
Every API route was calling getTenantFromRequest() independently, causing:
- **2.2M+ Redis GET requests per day**
- Slow response times (multiple Redis roundtrips per request)
- High database load during cache misses
Solution: Tenant Context Singleton
The new architecture extracts tenant **once per request** and makes it available everywhere via AsyncLocalStorage.
---
Migration Options
Option 1: Middleware (Recommended) ✅
**Best for**: New routes, major refactors
**Before**:
// src/app/api/users/route.ts
import { getTenantFromRequest } from '@/lib/tenant/tenant-extractor'
export async function GET(request: NextRequest) {
const tenant = await getTenantFromRequest(request) // ❌ Slow
if (!tenant) {
return NextResponse.json({ error: 'Tenant not found' }, { status: 404 })
}
const users = await db.query('SELECT * FROM users WHERE tenant_id = $1', [tenant.id])
return NextResponse.json({ users })
}**After**:
// src/app/api/users/route.ts
import { requireTenant } from '@/lib/middleware/tenant-middleware'
export const GET = requireTenant(async (tenant, request) => { // ✅ Fast
const users = await db.query('SELECT * FROM users WHERE tenant_id = $1', [tenant.id])
return NextResponse.json({ users })
})**Benefits**:
- ✅ Zero lookups (tenant pre-extracted by middleware)
- ✅ Cleaner code (no manual tenant checks)
- ✅ Type-safe (tenant guaranteed to exist)
---
Option 2: Optimized Extractor
**Best for**: Quick migration of existing routes
**Before**:
import { getTenantFromRequest } from '@/lib/tenant/tenant-extractor'
export async function GET(request: NextRequest) {
const tenant = await getTenantFromRequest(request) // ❌ Lookup every time
// ...
}**After**:
import { getTenantOrThrow } from '@/lib/tenant/tenant-extractor-v2'
export async function GET(request: NextRequest) {
const tenant = await getTenantOrThrow(request) // ✅ Cached after first call
// ...
}**Benefits**:
- ✅ Minimal code changes (1 import change)
- ✅ Automatic caching after first call
- ✅ Backward compatible
---
Option 3: Helper Functions
**Best for**: Services, utilities, background jobs
**Before**:
// src/lib/some-service.ts
export async function someServiceFunction(tenantId: string) {
// Need to pass tenantId everywhere
const data = await db.query('SELECT * FROM data WHERE tenant_id = $1', [tenantId])
return data
}
// Usage in API route:
export async function GET(request: NextRequest) {
const tenant = await getTenantFromRequest(request)
const data = await someServiceFunction(tenant.id) // ❌ Passing tenantId manually
}**After**:
// src/lib/some-service.ts
import { getCurrentTenantOrThrow } from '@/lib/tenant/tenant-extractor-v2'
export async function someServiceFunction() {
const tenant = getCurrentTenantOrThrow() // ✅ No parameters needed!
const data = await db.query('SELECT * FROM data WHERE tenant_id = $1', [tenant.id])
return data
}
// Usage in API route:
export const GET = requireTenant(async (tenant, request) => {
const data = await someServiceFunction() // ✅ No tenant passing needed
})**Benefits**:
- ✅ Cleaner function signatures (no tenant parameters)
- ✅ Automatic tenant context available everywhere
- ✅ Better testability
---
Middleware Setup
Step 1: Add to Next.js Middleware (Optional)
If you want tenant extraction to happen automatically for ALL routes:
// middleware.ts
import { withTenant } from '@/lib/middleware/tenant-middleware'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
return withTenant(request, () => {
// Tenant is now available via getCurrentTenant()
return NextResponse.next()
})
}
export const config = {
matcher: ['/api/:path*', '/dashboard/:path*'],
}Step 2: Migrate API Routes Gradually
You don't have to migrate all routes at once. The old and new patterns are compatible:
// This still works (but slower):
export async function GET(request: NextRequest) {
const tenant = await getTenantFromRequest(request) // ❌ Old way
// ...
}
// This is faster (recommended):
export const GET = requireTenant(async (tenant, request) => { // ✅ New way
// ...
})---
Testing
Test with Mock Tenant Context
import { withTenantContext, getCurrentTenant } from '@/lib/tenant/tenant-extractor-v2'
import { mockTenant } from '@/test/fixtures'
describe('My Service', () => {
it('should use tenant context', async () => {
await withTenantContext(mockTenant, async () => {
const tenant = getCurrentTenant()
expect(tenant?.id).toBe(mockTenant.id)
// Test your service functions here
const result = await myServiceFunction()
expect(result.tenantId).toBe(mockTenant.id)
})
})
})Clear Context Between Tests
import { clearTenantContext } from '@/lib/tenant/tenant-extractor-v2'
afterEach(() => {
clearTenantContext() // Clean up after each test
})---
Performance Monitoring
Track Cache Hit Rate
import { getTenantContextStats } from '@/lib/tenant/tenant-extractor-v2'
export async function GET(request: NextRequest) {
const stats = getTenantContextStats()
console.log('Tenant context stats:', stats)
// Output:
// {
// hasContext: true,
// contextAge: 45, // ms since extraction
// requestSignature: "req_1696838400000_abc123"
// }
// ... rest of handler
}---
Common Patterns
Conditional Tenant Logic
import { getCurrentTenant, hasTenantContext } from '@/lib/tenant/tenant-extractor-v2'
export async function publicEndpoint() {
if (hasTenantContext()) {
const tenant = getCurrentTenant()!
// Tenant-specific logic
} else {
// Public logic
}
}Background Jobs with Tenant Context
import { withTenantContext } from '@/lib/tenant/tenant-extractor-v2'
async function processBackgroundJob(tenantId: string) {
const tenant = await db.query('SELECT * FROM tenants WHERE id = $1', [tenantId])
await withTenantContext(tenant, async () => {
// All code here has access to tenant via getCurrentTenant()
await sendEmailNotifications()
await generateReports()
await cleanupOldData()
})
}Nested Function Calls
// API route
export const GET = requireTenant(async (tenant, request) => {
const result = await orchestrateWorkflow() // ✅ No tenant passing
return NextResponse.json({ result })
})
// Orchestrator
async function orchestrateWorkflow() {
const tenant = getCurrentTenantOrThrow() // ✅ Available here
const data = await fetchData() // ✅ And here
const processed = await processData(data) // ✅ And here
return processed
}
// Data service
async function fetchData() {
const tenant = getCurrentTenantOrThrow() // ✅ Still available!
return db.query('SELECT * FROM data WHERE tenant_id = $1', [tenant.id])
}
// Processing service
async function processData(data: any[]) {
const tenant = getCurrentTenantOrThrow() // ✅ Available throughout call stack
return data.filter(item => item.tenantId === tenant.id)
}---
Rollout Plan
Phase 1: Enable Middleware (Week 1)
- Add tenant middleware to
middleware.ts - Monitor logs for context age and hit rate
- Verify no breaking changes
Phase 2: Migrate High-Traffic Routes (Week 2-3)
- Identify top 20 routes by traffic
- Migrate to
requireTenantpattern - Measure performance improvement
Phase 3: Migrate Remaining Routes (Week 4-6)
- Migrate remaining API routes
- Update helper functions to use
getCurrentTenantOrThrow() - Remove tenant parameter passing
Phase 4: Cleanup (Week 7)
- Remove old
getTenantFromRequestcalls - Update documentation
- Deprecate old pattern
---
Expected Results
Before Migration
- **Redis GETs**: 2.2M per day
- **Avg latency**: 150ms per request (multiple Redis calls)
- **Code complexity**: High (tenant passing everywhere)
After Migration
- **Redis GETs**: ~440K per day (80% reduction)
- **Avg latency**: 50ms per request (1 Redis call per request)
- **Code complexity**: Low (tenant context automatic)
---
Troubleshooting
Issue: "Tenant context not available" error
**Cause**: Calling getCurrentTenantOrThrow() outside of request context
**Solution**:
// ❌ Wrong
export async function backgroundJob() {
const tenant = getCurrentTenantOrThrow() // Error!
}
// ✅ Correct
export async function backgroundJob(tenantId: string) {
const tenant = await db.query('SELECT * FROM tenants WHERE id = $1', [tenantId])
await withTenantContext(tenant, async () => {
const currentTenant = getCurrentTenantOrThrow() // Works!
// ... rest of job
})
}Issue: Tenant context is stale
**Cause**: Long-running request (>5 seconds)
**Solution**: Tenant context TTL is 5 seconds by design. For long-running operations, re-fetch tenant:
import { getTenantOrThrow } from '@/lib/tenant/tenant-extractor-v2'
export async function longRunningOperation(request: NextRequest) {
const tenant = await getTenantOrThrow(request)
// ... operation that takes >5 seconds
// Refresh tenant context if needed
const freshTenant = await getTenantOrThrow(request)
}---
FAQ
**Q: Do I need to migrate all routes at once?**
A: No, old and new patterns are compatible. Migrate gradually.
**Q: Will this break existing code?**
A: No, the old getTenantFromRequest() still works. This is an optimization.
**Q: What about server components?**
A: Server components can use getCurrentTenant() if wrapped in middleware, or pass tenant as prop.
**Q: How does this work with concurrent requests?**
A: AsyncLocalStorage ensures each request has its own isolated tenant context.
**Q: Can I use this in background jobs?**
A: Yes, use withTenantContext() to set tenant context for the job.
---
Need Help?
- 📖 See
/src/lib/middleware/tenant-middleware.tsfor middleware API - 📖 See
/src/lib/tenant/tenant-extractor-v2.tsfor helper functions - 📖 See
/src/lib/tenant/tenant-context.tsfor implementation details