req.nextUrl.pathname;
if (!userId) {
return NextResponse.redirect(new URL('/auth/sign-in', req.url));
}
try {
const userSession = await clerkClient.users.getUser(userId);
const tenantId = userSession.privateMetadata?.tenant_id as string;
if (!tenantId) {
return NextResponse.json({ error: 'Tenant context missing' }, { status: 403 });
}
const scopedDb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, {
global: { headers: { 'x-tenant-id': tenantId } }
});
const { data: subscription } = await scopedDb
.from('tenant_subscriptions')
.select('status, tier_level')
.eq('tenant_id', tenantId)
.single();
if (subscription?.status !== 'active') {
return NextResponse.json({ error: 'Subscription inactive' }, { status: 402 });
}
req.headers.set('x-tenant-scope', JSON.stringify({
id: tenantId,
tier: subscription.tier_level,
db: scopedDb
}));
return NextResponse.next();
} catch (err) {
console.error('Tenant guard failure:', err);
return NextResponse.json({ error: 'Authorization check failed' }, { status: 500 });
}
}
**Rationale:** Decoupling identity verification from permission enforcement prevents auth provider lock-in. The middleware attaches a pre-configured Supabase client to the request headers, ensuring every downstream route operates within strict tenant boundaries without repeating boilerplate queries.
### 3. Asynchronous Job Processing
Blocking the main event loop with email dispatch, PDF generation, or third-party sync operations degrades UX. A queue-based worker pattern isolates heavy I/O.
```typescript
// workers/task-dispatcher.ts
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
const redisConnection = new IORedis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
export const operationQueue = new Queue('platform-tasks', { connection: redisConnection });
export const taskWorker = new Worker(
'platform-tasks',
async (job) => {
const { type, payload } = job.data;
switch (type) {
case 'GENERATE_INVOICE':
await generatePdfReport(payload.tenantId, payload.invoiceId);
break;
case 'SYNC_BILLING_STATUS':
await reconcileSubscriptionState(payload.stripeCustomerId);
break;
case 'NOTIFY_OPERATOR':
await dispatchTransactionalEmail(payload.recipient, payload.template);
break;
default:
throw new Error(`Unknown task type: ${type}`);
}
},
{ connection: redisConnection, concurrency: 5 }
);
async function generatePdfReport(tenantId: string, invoiceId: string) {
// Implementation: fetch data, render template, upload to Supabase Storage
console.log(`[Worker] Generating invoice ${invoiceId} for ${tenantId}`);
}
async function reconcileSubscriptionState(customerId: string) {
// Implementation: fetch Stripe status, update Supabase tenant record
console.log(`[Worker] Syncing billing state for ${customerId}`);
}
async function dispatchTransactionalEmail(recipient: string, template: string) {
// Implementation: route through SES/SendGrid
console.log(`[Worker] Dispatching ${template} to ${recipient}`);
}
Rationale: BullMQ provides reliable job persistence, retry logic, and concurrency control. Offloading I/O-bound operations ensures the HTTP response cycle remains under 200ms. The worker processes tasks independently, preventing event loop starvation during peak onboarding or billing cycles.
4. Webhook Orchestration & Dynamic Configuration
Stripe handles payment compliance, but webhook delivery is unreliable. A sync engine reconciles state changes and updates Strapi for dynamic content management without code deployments.
// api/billing-sync-engine.ts
import { NextApiRequest, NextApiResponse } from 'next';
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' });
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
export default async function billingSyncHandler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end();
const signature = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return res.status(400).send('Webhook signature verification failed');
}
const idempotencyKey = `stripe_evt_${event.id}`;
const { data: processed } = await supabase
.from('webhook_log')
.select('id')
.eq('event_id', idempotencyKey)
.single();
if (processed) return res.status(200).json({ received: true });
await supabase.from('webhook_log').insert({ event_id: idempotencyKey, payload: event });
switch (event.type) {
case 'invoice.payment_succeeded':
await operationQueue.add('SYNC_BILLING_STATUS', {
stripeCustomerId: (event.data.object as Stripe.Invoice).customer as string
});
break;
case 'customer.subscription.deleted':
await operationQueue.add('NOTIFY_OPERATOR', {
recipient: (event.data.object as Stripe.Subscription).metadata.admin_email,
template: 'subscription_expired'
});
break;
}
res.status(200).json({ received: true });
}
Rationale: Webhook handlers must be idempotent. Stripe retries deliveries, and duplicate processing corrupts tenant states. Logging event IDs before execution guarantees exactly-once semantics. Queuing state reconciliation decouples payment confirmation from application logic, allowing Strapi to serve updated tier configurations without triggering full application rebuilds.
Pitfall Guide
1. Blurring Authentication and Authorization Boundaries
Explanation: Relying on Clerk or Auth0 to enforce tenant access leads to permission leaks. Identity providers verify who the user is; they do not understand your business rules or data partitioning.
Fix: Always validate tenant context in application middleware. Use database-level Row-Level Security (RLS) as a secondary enforcement layer. Never trust client-supplied tenant IDs.
2. Synchronous Webhook Processing
Explanation: Handling Stripe or n8n webhooks synchronously blocks the API thread. If the database is slow or the downstream service times out, the payment provider marks your endpoint as unhealthy and stops sending events.
Fix: Acknowledge the webhook immediately with a 200 OK, log the event, and push processing to a background queue. Implement exponential backoff for retries.
3. Hardcoding Tenant Scopes in Queries
Explanation: Manually appending WHERE tenant_id = ? to every query is error-prone and creates maintenance debt. Developers inevitably miss a query, causing cross-tenant data leakage.
Fix: Use Supabase RLS policies or a middleware-attached scoped client. Centralize data access through repository functions that automatically inject the tenant context from the request scope.
4. Over-Reliance on Low-Code for Core Logic
Explanation: Routing critical business workflows through Google Forms or n8n without validation layers introduces silent failures. Low-code tools lack type safety and debugging visibility.
Fix: Treat low-code pipelines as ingestion endpoints only. Validate all incoming payloads against a TypeScript schema (e.g., Zod) before persisting to the database. Maintain audit logs for every automated transition.
5. Ignoring Connection Pool Limits
Explanation: Supabase and Node.js both manage connections, but misconfigured pool sizes cause FATAL: too many connections errors during traffic spikes.
Fix: Configure max and idle timeouts explicitly. Use PgBouncer in transaction mode if scaling beyond 500 concurrent requests. Monitor pool utilization via Supabase metrics and set alerts at 80% capacity.
6. Monolithic Deployment Bottlenecks
Explanation: A single deploy unit means a frontend CSS change requires rebuilding the entire backend. This slows iteration and increases rollback risk.
Fix: Implement modular boundaries using feature folders and explicit dependency graphs. Use Next.js App Router with route segments to isolate UI changes. Keep backend services stateless so frontend rebuilds do not invalidate server caches.
7. Missing Idempotency in Financial Operations
Explanation: Network retries, user double-clicks, or webhook duplicates cause double-charging or duplicate invoice generation.
Fix: Generate client-side idempotency keys for all payment mutations. Store processed event IDs in a dedicated log table. Reject duplicate requests before executing business logic.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| 0β10k tenants, <3 month launch | Pragmatic Monolith + Supabase + Clerk | Unified transactions, minimal infra overhead, fastest iteration | Low (Managed services scale linearly) |
| 10kβ50k tenants, compliance-heavy | Monolith + Dedicated AuthZ Layer + PgBouncer | Stricter data partitioning, audit trails, connection stability | Medium (Increased DB compute & monitoring) |
| Multi-region deployment, latency-sensitive | Edge-optimized Monolith + Regional Read Replicas | Reduced cross-region latency, centralized business logic | Medium-High (CDN + replica provisioning) |
| Independent scaling required (e.g., video processing) | Extract domain to microservice | Prevents resource contention, allows independent scaling | High (Service mesh, cross-service auth, monitoring) |
Configuration Template
# .env.production
# Core Platform
NEXT_PUBLIC_APP_URL=https://app.yourdomain.com
NODE_ENV=production
# Database & Realtime
SUPABASE_PROJECT_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-key
# Identity & Access
CLERK_SECRET_KEY=sk_live_...
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
# Payments & Webhooks
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Async Processing
REDIS_URL=redis://default:password@redis-host:6379
BULLMQ_CONCURRENCY=5
# CMS & Dynamic Config
STRAPI_ADMIN_URL=https://cms.yourdomain.com
STRAPI_API_TOKEN=your-token
// lib/queue-config.ts
import { Queue, Worker, Connection } from 'bullmq';
export const redisConnection = new Connection({
url: process.env.REDIS_URL!,
maxRetriesPerRequest: null,
enableReadyCheck: false
});
export const platformQueue = new Queue('core-operations', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100,
removeOnFail: 50
}
});
export const operationWorker = new Worker(
'core-operations',
async (job) => {
const { type, payload } = job.data;
// Route to domain handlers
await dispatchTask(type, payload);
},
{
connection: redisConnection,
concurrency: Number(process.env.BULLMQ_CONCURRENCY || 5),
limiter: { max: 100, duration: 1000 }
}
);
Quick Start Guide
- Initialize the repository: Run
npx create-next-app@latest platform-core --typescript --app and install dependencies: npm i @supabase/supabase-js @clerk/nextjs stripe bullmq ioreds zod.
- Configure environment variables: Copy the
.env.production template, provision a Supabase project, create a Clerk application, and generate Stripe webhook secrets. Add them to your local environment.
- Deploy the database schema: Execute the Supabase SQL migration to create
tenants, tenant_subscriptions, and webhook_log tables. Enable Row-Level Security and attach tenant isolation policies.
- Start the async workers: Run
npx ts-node workers/task-dispatcher.ts in a separate terminal to initialize the BullMQ consumer. Verify Redis connectivity and queue health.
- Launch the application: Execute
npm run dev and navigate to http://localhost:3000. Test tenant onboarding via the Clerk sign-up flow, trigger a test Stripe webhook, and confirm background job execution in the worker logs.