osmoto.
Case StudiesBlogBook Consultation

Services

Stripe IntegrationSubscription BillingPayment Automation & AINext.js OptimizationAudit & Fix

Solutions

For FoundersFor SaaS CompaniesFor E-Commerce StoresFor Marketplaces

Resources

Implementation GuideWebhook Best PracticesPCI Compliance GuideStripe vs Alternatives
Case StudiesBlog
Book Consultation
osmoto.

Professional Stripe integration services

Services

  • Stripe Integration
  • Subscription Billing
  • E-Commerce Integration
  • Next.js Optimization
  • Audit & Fix

Solutions

  • For Founders
  • For SaaS
  • For E-Commerce
  • For Marketplaces
  • Integration as a Service

Resources

  • Implementation Guide
  • Webhook Guide
  • PCI Compliance
  • Stripe vs Alternatives

Company

  • About
  • Case Studies
  • Process
  • Pricing
  • Contact
© 2026 Osmoto · Professional Stripe Integration Services
Back to Blog
SaaS12 min read

Building a Multi-Tenant SaaS Application with Stripe and Next.js

When you're building a multi-tenant SaaS application, the architecture decisions you make early on will either scale gracefully or become technical debt that ha...

Osmoto Team

Senior Software Engineer

January 18, 2026
Building a Multi-Tenant SaaS Application with Stripe and Next.js

When you're building a multi-tenant SaaS application, the architecture decisions you make early on will either scale gracefully or become technical debt that haunts you at 3 AM. After implementing dozens of SaaS billing systems, I've seen teams struggle with tenant isolation, subscription management complexity, and webhook handling at scale. The combination of Next.js and Stripe provides a robust foundation, but the devil is in the implementation details.

Multi-tenant architecture isn't just about separating customer data—it's about creating a system that can handle thousands of tenants with different billing requirements, usage patterns, and customization needs. When you add Stripe's subscription billing into the mix, you're dealing with webhook race conditions, failed payment handling across tenants, and the complexity of managing multiple subscription states simultaneously.

This guide walks through building a production-ready multi-tenant SaaS architecture using Next.js 14 with the App Router and Stripe's latest APIs. We'll cover tenant isolation strategies, subscription management patterns, webhook handling at scale, and the specific implementation details that separate hobby projects from enterprise-ready systems.

Multi-Tenant Architecture Patterns

The first critical decision is how to isolate tenant data. There are three primary patterns, each with distinct trade-offs for Stripe integration.

Database-Level Isolation

The most secure approach uses separate databases per tenant. This provides complete data isolation but complicates Stripe webhook handling since you need to route webhooks to the correct database.

// lib/tenant-db.ts import { PrismaClient } from '@prisma/client' class TenantDatabaseManager { private connections = new Map<string, PrismaClient>() async getConnection(tenantId: string): Promise<PrismaClient> { if (!this.connections.has(tenantId)) { const connectionString = `${process.env.DATABASE_URL}_${tenantId}` const prisma = new PrismaClient({ datasources: { db: { url: connectionString } } }) this.connections.set(tenantId, prisma) } return this.connections.get(tenantId)! } async executeForTenant<T>( tenantId: string, operation: (db: PrismaClient) => Promise<T> ): Promise<T> { const db = await this.getConnection(tenantId) return operation(db) } } export const tenantDb = new TenantDatabaseManager()

Schema-Level Isolation

A middle-ground approach uses database schemas to separate tenants. This works well with PostgreSQL and simplifies connection management while maintaining strong isolation.

// lib/schema-tenant.ts import { PrismaClient } from '@prisma/client' export class SchemaTenantManager { private prisma: PrismaClient constructor() { this.prisma = new PrismaClient() } async withTenant<T>( tenantId: string, operation: (db: PrismaClient) => Promise<T> ): Promise<T> { // Set the schema for this operation await this.prisma.$executeRawUnsafe(`SET search_path TO tenant_${tenantId}`) try { return await operation(this.prisma) } finally { // Reset to default schema await this.prisma.$executeRawUnsafe(`SET search_path TO public`) } } }

Row-Level Security (RLS)

For most SaaS applications, row-level security provides the best balance of simplicity and isolation. Every table includes a tenant_id column, and database policies enforce access control.

-- Enable RLS on subscription table ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY; -- Policy to ensure users only see their tenant's data CREATE POLICY tenant_isolation ON subscriptions FOR ALL TO authenticated USING (tenant_id = current_setting('app.current_tenant')::uuid);
// lib/rls-tenant.ts import { PrismaClient } from '@prisma/client' export class RLSTenantManager { private prisma: PrismaClient constructor() { this.prisma = new PrismaClient() } async withTenant<T>( tenantId: string, operation: (db: PrismaClient) => Promise<T> ): Promise<T> { // Set tenant context for RLS await this.prisma.$executeRawUnsafe( `SELECT set_config('app.current_tenant', $1, true)`, tenantId ) return operation(this.prisma) } }

Stripe Customer and Subscription Management

Each tenant needs its own Stripe customer hierarchy, but you have two options: separate Stripe accounts per tenant or a single Stripe account with metadata-based organization.

Single Stripe Account with Metadata

For most applications, using a single Stripe account with proper metadata organization is simpler and more cost-effective.

// lib/stripe-tenant.ts import Stripe from 'stripe' export class TenantStripeManager { private stripe: Stripe constructor() { this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' }) } async createTenantCustomer(tenantId: string, customerData: { email: string name: string userId: string }) { return this.stripe.customers.create({ email: customerData.email, name: customerData.name, metadata: { tenant_id: tenantId, user_id: customerData.userId, environment: process.env.NODE_ENV } }) } async createTenantSubscription( customerId: string, priceId: string, tenantId: string, options: { trial_period_days?: number default_payment_method?: string } = {} ) { return this.stripe.subscriptions.create({ customer: customerId, items: [{ price: priceId }], metadata: { tenant_id: tenantId, created_by: 'saas_app' }, ...options }) } async getTenantSubscriptions(tenantId: string) { const subscriptions = await this.stripe.subscriptions.list({ limit: 100, expand: ['data.customer'] }) return subscriptions.data.filter( sub => sub.metadata.tenant_id === tenantId ) } }

Tenant-Specific Pricing

Different tenants often need different pricing structures. Handle this with Stripe's price metadata and tenant-specific price mappings.

// lib/tenant-pricing.ts interface TenantPricing { tenantId: string planType: 'startup' | 'growth' | 'enterprise' customPrices?: { monthly?: string // Stripe price ID yearly?: string } } export class TenantPricingManager { private stripe: Stripe constructor() { this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) } async getPriceForTenant( tenantId: string, basePlanType: string, interval: 'month' | 'year' ): Promise<string> { // Check for tenant-specific pricing first const tenantPricing = await this.getTenantPricing(tenantId) if (tenantPricing?.customPrices) { const customPrice = interval === 'month' ? tenantPricing.customPrices.monthly : tenantPricing.customPrices.yearly if (customPrice) return customPrice } // Fall back to standard pricing return this.getStandardPrice(basePlanType, interval) } private async getTenantPricing(tenantId: string): Promise<TenantPricing | null> { // Implementation depends on your data store // Could be database, Redis, or external service return null } private getStandardPrice(planType: string, interval: string): string { const priceMap = { 'basic_month': 'price_basic_monthly', 'basic_year': 'price_basic_yearly', 'pro_month': 'price_pro_monthly', 'pro_year': 'price_pro_yearly' } return priceMap[`${planType}_${interval}`] || '' } }

Next.js App Router Implementation

The App Router provides excellent patterns for tenant-aware routing and middleware. Here's how to structure a multi-tenant SaaS application.

Tenant Middleware

Middleware handles tenant resolution from subdomains or custom domains before any page renders.

// middleware.ts import { NextRequest, NextResponse } from 'next/server' import { getTenantFromHost } from '@/lib/tenant-resolver' export async function middleware(request: NextRequest) { const hostname = request.headers.get('host')! const tenant = await getTenantFromHost(hostname) if (!tenant) { // Redirect to main marketing site or error page return NextResponse.redirect(new URL('/no-tenant', request.url)) } // Add tenant info to headers for downstream consumption const response = NextResponse.next() response.headers.set('x-tenant-id', tenant.id) response.headers.set('x-tenant-slug', tenant.slug) return response } export const config = { matcher: [ '/((?!api/webhooks|_next/static|_next/image|favicon.ico).*)', ], }
// lib/tenant-resolver.ts interface Tenant { id: string slug: string customDomain?: string stripeCustomerId?: string } export async function getTenantFromHost(hostname: string): Promise<Tenant | null> { // Handle custom domains if (!hostname.includes('.yourapp.com')) { return getTenantByCustomDomain(hostname) } // Handle subdomains const subdomain = hostname.split('.')[0] if (subdomain && subdomain !== 'www') { return getTenantBySlug(subdomain) } return null } async function getTenantBySlug(slug: string): Promise<Tenant | null> { // Database lookup const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true, slug: true, customDomain: true, stripeCustomerId: true } }) return tenant }

Tenant-Aware API Routes

API routes need tenant context for all operations, especially Stripe interactions.

// app/api/subscriptions/route.ts import { NextRequest } from 'next/server' import { getTenantFromHeaders } from '@/lib/tenant-utils' import { TenantStripeManager } from '@/lib/stripe-tenant' export async function GET(request: NextRequest) { try { const tenant = getTenantFromHeaders(request.headers) if (!tenant) { return Response.json({ error: 'Tenant not found' }, { status: 404 }) } const stripeManager = new TenantStripeManager() const subscriptions = await stripeManager.getTenantSubscriptions(tenant.id) return Response.json({ subscriptions }) } catch (error) { console.error('Error fetching subscriptions:', error) return Response.json( { error: 'Failed to fetch subscriptions' }, { status: 500 } ) } } export async function POST(request: NextRequest) { try { const tenant = getTenantFromHeaders(request.headers) const { priceId, customerId } = await request.json() const stripeManager = new TenantStripeManager() const subscription = await stripeManager.createTenantSubscription( customerId, priceId, tenant.id ) return Response.json({ subscription }) } catch (error) { return Response.json( { error: 'Failed to create subscription' }, { status: 500 } ) } }

Tenant Context Hook

Create a React context to provide tenant information throughout your application.

// contexts/tenant-context.tsx 'use client' import { createContext, useContext, ReactNode } from 'react' interface TenantContextType { tenant: { id: string name: string slug: string plan: string stripeCustomerId?: string } | null } const TenantContext = createContext<TenantContextType>({ tenant: null }) interface TenantProviderProps { children: ReactNode tenant: TenantContextType['tenant'] } export function TenantProvider({ children, tenant }: TenantProviderProps) { return ( <TenantContext.Provider value={{ tenant }}> {children} </TenantContext.Provider> ) } export function useTenant() { const context = useContext(TenantContext) if (context === undefined) { throw new Error('useTenant must be used within a TenantProvider') } return context }

Webhook Handling at Scale

Multi-tenant webhook handling is where many implementations break down. You need to route webhooks to the correct tenant and handle them atomically.

Webhook Router

// app/api/webhooks/stripe/route.ts import { NextRequest } from 'next/server' import Stripe from 'stripe' import { WebhookProcessor } from '@/lib/webhook-processor' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) export async function POST(request: NextRequest) { const body = await request.text() const signature = request.headers.get('stripe-signature')! try { const event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ) const processor = new WebhookProcessor() await processor.process(event) return Response.json({ received: true }) } catch (error) { console.error('Webhook error:', error) return Response.json( { error: 'Webhook handler failed' }, { status: 400 } ) } }

Tenant-Aware Webhook Processing

// lib/webhook-processor.ts import Stripe from 'stripe' import { tenantDb } from '@/lib/tenant-db' export class WebhookProcessor { async process(event: Stripe.Event) { // Extract tenant ID from event metadata const tenantId = this.extractTenantId(event) if (!tenantId) { console.error('No tenant ID found in webhook event:', event.id) return } // Process with tenant context await tenantDb.executeForTenant(tenantId, async (db) => { switch (event.type) { case 'customer.subscription.created': return this.handleSubscriptionCreated(event.data.object, db) case 'customer.subscription.updated': return this.handleSubscriptionUpdated(event.data.object, db) case 'invoice.payment_failed': return this.handlePaymentFailed(event.data.object, db, tenantId) default: console.log(`Unhandled event type: ${event.type}`) } }) } private extractTenantId(event: Stripe.Event): string | null { const obj = event.data.object as any // Try metadata first if (obj.metadata?.tenant_id) { return obj.metadata.tenant_id } // For customer events, check customer metadata if (obj.customer && typeof obj.customer === 'string') { // Would need to fetch customer to get metadata // This is why including tenant_id in all relevant objects is important } return null } private async handleSubscriptionCreated( subscription: Stripe.Subscription, db: any ) { await db.subscription.create({ data: { stripeSubscriptionId: subscription.id, stripeCustomerId: subscription.customer as string, status: subscription.status, currentPeriodStart: new Date(subscription.current_period_start * 1000), currentPeriodEnd: new Date(subscription.current_period_end * 1000), // Map other relevant fields } }) } private async handlePaymentFailed( invoice: Stripe.Invoice, db: any, tenantId: string ) { // Update subscription status await db.subscription.update({ where: { stripeSubscriptionId: invoice.subscription as string }, data: { status: 'past_due', lastPaymentFailed: new Date() } }) // Trigger tenant-specific failed payment flow await this.triggerFailedPaymentFlow(tenantId, invoice) } private async triggerFailedPaymentFlow(tenantId: string, invoice: Stripe.Invoice) { // Implementation depends on your notification system // Could be email, in-app notifications, or external service } }

Common Pitfalls and Edge Cases

Race Conditions in Webhook Processing

When multiple webhooks arrive simultaneously for the same subscription, you can end up with inconsistent state. Use database transactions and idempotency keys.

// lib/idempotent-webhook.ts export class IdempotentWebhookProcessor { async processWithIdempotency(event: Stripe.Event, tenantId: string) { return await tenantDb.executeForTenant(tenantId, async (db) => { // Check if we've already processed this event const existingEvent = await db.webhookEvent.findUnique({ where: { stripeEventId: event.id } }) if (existingEvent) { console.log(`Event ${event.id} already processed`) return existingEvent.result } // Process within transaction return await db.$transaction(async (tx) => { // Record that we're processing this event await tx.webhookEvent.create({ data: { stripeEventId: event.id, eventType: event.type, status: 'processing', createdAt: new Date(event.created * 1000) } }) // Process the actual event const result = await this.processEvent(event, tx) // Mark as completed await tx.webhookEvent.update({ where: { stripeEventId: event.id }, data: { status: 'completed', result: JSON.stringify(result) } }) return result }) }) } }

Tenant Isolation Failures

Always validate tenant access in API routes, even when using middleware. Middleware can be bypassed in certain deployment scenarios.

// lib/tenant-validation.ts export async function validateTenantAccess( requestTenantId: string, userId: string ): Promise<boolean> { const userTenantAccess = await prisma.userTenant.findFirst({ where: { userId, tenantId: requestTenantId, status: 'active' } }) return !!userTenantAccess } // Usage in API route export async function GET(request: NextRequest) { const tenant = getTenantFromHeaders(request.headers) const userId = getUserFromSession(request) const hasAccess = await validateTenantAccess(tenant.id, userId) if (!hasAccess) { return Response.json({ error: 'Access denied' }, { status: 403 }) } // Proceed with request }

Subscription State Synchronization

Stripe subscription status and your local database can get out of sync. Implement reconciliation jobs and health checks.

// lib/subscription-sync.ts export class SubscriptionSyncService { async syncTenantSubscriptions(tenantId: string) { const stripeManager = new TenantStripeManager() const stripeSubscriptions = await stripeManager.getTenantSubscriptions(tenantId) await tenantDb.executeForTenant(tenantId, async (db) => { for (const stripeSub of stripeSubscriptions) { const localSub = await db.subscription.findUnique({ where: { stripeSubscriptionId: stripeSub.id } }) if (!localSub) { // Create missing subscription await db.subscription.create({ data: this.mapStripeSubscription(stripeSub) }) } else if (this.needsUpdate(localSub, stripeSub)) { // Update existing subscription await db.subscription.update({ where: { id: localSub.id }, data: this.mapStripeSubscription(stripeSub) }) } } }) } private needsUpdate(local: any, stripe: Stripe.Subscription): boolean { return ( local.status !== stripe.status || local.currentPeriodEnd.getTime() !== stripe.current_period_end * 1000 ) } }

Best Practices Summary

Architecture Decisions:

  • Use row-level security for most applications unless you have specific compliance requirements
  • Implement tenant validation at multiple layers (middleware, API routes, database)
  • Design your data model with tenant isolation from the start

Stripe Integration:

  • Always include tenant_id in Stripe metadata for all objects
  • Use idempotency keys for all mutating Stripe operations
  • Implement webhook replay and reconciliation mechanisms

Performance Considerations:

  • Cache tenant lookups with Redis or similar
  • Use database connection pooling appropriately for your isolation strategy
  • Implement proper indexing on tenant_id columns

Security Requirements:

  • Validate tenant access in every API route
  • Use separate webhook endpoints if you need different security policies per tenant
  • Implement audit logging for cross-tenant operations

Monitoring and Observability:

  • Track webhook processing latency and failure rates per tenant
  • Monitor subscription synchronization health
  • Alert on tenant isolation failures

Next Steps and Advanced Patterns

Building a production-ready multi-tenant SaaS application requires careful attention to architecture, security, and scalability. The patterns outlined here provide a solid foundation, but real-world applications often need additional complexity around usage-based billing, marketplace integrations, and advanced subscription management.

For teams implementing complex subscription billing scenarios, consider reviewing our guide on prorated Stripe subscription upgrades and downgrades for handling mid-cycle plan changes. The webhook implementation patterns discussed here also benefit from the idempotency strategies covered in our idempotent API endpoints guide.

If you're building a multi-tenant SaaS application and need expert guidance on the subscription billing architecture, our SaaS solutions service provides end-to-end implementation support for complex billing scenarios, including multi-tenant webhook handling, usage-based billing, and marketplace integrations.

Related Articles

Implementing Feature Flags for SaaS Subscription Tiers
SaaS
Implementing Feature Flags for SaaS Subscription Tiers
When your SaaS hits product-market fit, you'll face a familiar challenge: customers requesting features that would be perfect for a higher-tier plan, while your...
How to Reduce SaaS Churn with Better Payment Failure Handling
SaaS
How to Reduce SaaS Churn with Better Payment Failure Handling
Your SaaS just lost another customer. Not because they didn't love your product, not because a competitor offered better features, but because their credit card...

Need Expert Implementation?

I provide professional Stripe integration and Next.js optimization services with fixed pricing and fast delivery.