Multi-tenant SaaS app with Next.js and Cloudflare
Building a production-ready SaaS platform where each tenant gets their own subdomain and can add custom domains with automatic SSL certificates.
Introduction: Why Multi-Tenancy Matters
Picture this: You're building the next Notion, Webflow, or Framer. Your users need their own branded spaces—client.yourplatform.com—but they also want to use their own domains like clientdomain.com.
This is multi-tenancy at its finest, and it's what powers every major SaaS platform today. The challenge? Each tenant needs:
- Their own subdomain (client.yourplatform.com)
- Optional custom domains (clientdomain.com) pointing to their content
- Automatic SSL certificates for security
- Global edge performance for fast loading
The solution? Next.js + OpenNext + Cloudflare Workers + Cloudflare for SaaS.
In this guide, you'll build a production-ready platform where users can:
- Get their own subdomain instantly
- Add custom domains through a simple settings page
- Have SSL certificates automatically provisioned
- Deploy to Cloudflare's global edge network
Let's dive in.
Architecture Overview
Here's how the magic works:
Key Concepts
Subdomain Routing: When someone visits client.yourplatform.com, our middleware extracts client and rewrites the request to /s/client, serving tenant-specific content.
Custom Domain Routing: When someone visits clientdomain.com, we look up the domain in Redis to find which subdomain it maps to, then rewrite accordingly.
Cloudflare for SaaS: This handles the heavy lifting—SSL certificates, domain verification, and traffic routing for custom domains.
OpenNext Adapter: Converts your Next.js app to run on Cloudflare Workers while maintaining full compatibility.
Redis Storage: Stores tenant data and custom domain mappings using simple key patterns:
- subdomain:tenant-name → tenant data
- custom-domain:clientdomain.com → subdomain mapping
Technology Stack
- Next.js 15 (App Router) - React framework
- OpenNext Cloudflare - Next.js → Workers adapter
- Cloudflare Workers - Edge runtime
- Cloudflare for SaaS - Custom Hostnames API
- Upstash Redis - Data storage
- Tailwind CSS - Styling
Getting Started: Clone & Setup
Prerequisites
- Node.js 18+
- pnpm installed
- Cloudflare account with Workers enabled
- Upstash Redis account
Step 1: Clone the Repository
codegit clone https://github.com/florianheysen/platforms-cf-worker.gitcd platforms-cf-workerpnpm install
Step 2: Environment Variables
Create .env.local in the root directory:
code# Cloudflare APICLOUDFLARE_API_TOKEN=your_api_token_hereCLOUDFLARE_ZONE_ID=your_zone_id_hereCLOUDFLARE_ACCOUNT_ID=your_account_id_here# Root domainROOT_DOMAIN=yourplatform.comPROTOCOL=https# Redis (Upstash)UPSTASH_REDIS_REST_URL=your_redis_urlUPSTASH_REDIS_REST_TOKEN=your_redis_token
Step 3: Local Development
codepnpm dev
Access your app:
- Main site: http://localhost:3000
- Admin panel: http://localhost:3000/admin
- Test tenant: http://client.localhost:3000
How It Works: Deep Dive
Middleware Routing Logic
The heart of our multi-tenancy system lives in middleware.ts. Let's break down how it works:
Subdomain Extraction
codefunction extractSubdomain(request: NextRequest): string | null { const url = request.url; const host = request.headers.get('host') || ''; const hostname = host.split(':')[0]; // Local development if (url.includes('localhost') || url.includes('127.0.0.1')) { const fullUrlMatch = url.match(/http:\/\/([^.]+)\.localhost/); if (fullUrlMatch && fullUrlMatch[1]) { return fullUrlMatch[1]; } return null; } // Production environment const rootDomainFormatted = rootDomain.split(':')[0]; // Handle Cloudflare Workers preview URLs if (hostname.includes('---') && hostname.endsWith('.pages.dev')) { const parts = hostname.split('---'); return parts.length > 0 ? parts[0] : null; } // Regular subdomain detection const isSubdomain = hostname !== rootDomainFormatted && hostname !== `www.${rootDomainFormatted}` && hostname.endsWith(`.${rootDomainFormatted}`); return isSubdomain ? hostname.replace(`.${rootDomainFormatted}`, '') : null;}
This function handles three scenarios:
- Local development: client.localhost:3000 → client
- Production subdomains: client.yourplatform.com → client
- Preview deployments: client---branch.pages.dev → client
Custom Domain Lookup
code// Check if this hostname is a custom domainconst customDomainData = await getSubdomainByCustomDomain(hostname);if (customDomainData) { // Block access to admin page from custom domains if (pathname.startsWith('/admin')) { return NextResponse.redirect(new URL('/', request.url)); } // Rewrite all paths on a custom domain to the associated subdomain page return NextResponse.rewrite( new URL(`/s/${customDomainData.subdomain}${pathname}`, request.url) );}
When someone visits clientdomain.com, we:
- Look up the domain in Redis
- Find which subdomain it maps to
- Rewrite the request to /s/subdomain
- Serve the same content as the subdomain
Domain Utilities & Cloudflare API
The lib/domain-utils.ts file handles all Cloudflare for SaaS interactions:
Adding a Custom Hostname
codeexport async function addCustomHostnameToCloudflare(domain: string) { const normalizedDomain = normalizeDomain(domain); const response = await fetch( `${CLOUDFLARE_API_BASE}/zones/${CLOUDFLARE_ZONE_ID}/custom_hostnames`, { method: 'POST', headers: { 'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ hostname: normalizedDomain, ssl: { method: 'txt', type: 'dv', settings: { http2: 'on', min_tls_version: '1.2', tls_1_3: 'on' } } }), } ); const responseData = await response.json(); const customHostname = responseData.result; // Generate DCV delegation CNAME record details const dcvDelegation = { cnameName: `_acme-challenge.${normalizedDomain}`, cnameValue: `${normalizedDomain}.cf08b1ef9b3fff4e.dcv.cloudflare.com` }; return { success: true, dcvDelegation, txtRecords: verificationRecords, verificationRequired: true };}
This function:
- Validates the domain format
- Calls Cloudflare's Custom Hostnames API
- Returns DNS records needed for verification
- Generates DCV delegation for automatic certificate renewal
Domain Verification
codeexport async function verifyCustomHostnameCloudflare(domain: string) { // Get the custom hostname from Cloudflare const listResponse = await fetch( `${CLOUDFLARE_API_BASE}/zones/${CLOUDFLARE_ZONE_ID}/custom_hostnames` ); const listData = await listResponse.json(); const hostname = listData.result?.find((h: any) => h.hostname === domain); if (!hostname) { return { verified: false, error: 'Custom hostname not found' }; } // Check SSL status const response = await fetch( `${CLOUDFLARE_API_BASE}/zones/${CLOUDFLARE_ZONE_ID}/custom_hostnames/${hostname.id}` ); const data = await response.json(); const customHostname = data.result; if (customHostname.ssl?.status === 'active') { return { verified: true }; } else if (customHostname.ssl?.status === 'pending_validation') { return { verified: false, verificationRequired: true, txtRecords: extractTxtRecords(customHostname) }; }}
This checks the SSL certificate status and returns verification records if needed.
Cloudflare Configuration
Get Cloudflare API Credentials
Step 1: Create API Token
- Go to Cloudflare Dashboard
- Click "Create Token"
- Use "Custom token" template
- Set permissions:
- Zone:Zone:Read
- Zone:Custom Hostnames:Edit
- Zone Resources: Include → Specific zone → Select your domain
- Click "Continue to summary" → "Create Token"
- Copy the token (you won't see it again!)
Step 2: Get Zone ID & Account ID
- Go to your domain in Cloudflare Dashboard
- Scroll to "API" section (right sidebar)
- Copy Zone ID and Account ID
Configure wrangler.toml
This file tells Cloudflare Workers how to deploy your app:
codename = "platforms-cf-worker"main = ".open-next/worker.js"compatibility_date = "2024-09-23"compatibility_flags = ["nodejs_compat"]# Routes for all trafficroutes = [ { pattern = "*.yourplatform.com/*", zone_name = "yourplatform.com" }, { pattern = "yourplatform.com/*", zone_name = "yourplatform.com" }, { pattern = "*/*", zone_name = "yourplatform.com" } # For custom domains]# Assets configuration for OpenNext[assets]directory = ".open-next/assets"binding = "ASSETS"# Environment variables[vars]ROOT_DOMAIN = "yourplatform.com"PROTOCOL = "https"NEXT_PUBLIC_ROOT_DOMAIN = "yourplatform.com"# Production environment[env.production]name = "platforms-cf-worker"NODE_ENV = "production"CLOUDFLARE_API_TOKEN = "your_token_here"CLOUDFLARE_ZONE_ID = "your_zone_id_here"
Key points:
- Wildcard routes (*.yourplatform.com/*) handle all subdomains
- Catch-all route (*/*) handles custom domains
- Assets binding serves static files from .open-next/assets
DNS Setup in Cloudflare
Wildcard DNS Record
- Go to DNS → Records
- Click "Add record"
- Configure:
- Type: A or CNAME
- Name: *
- Content: @ (or your Workers domain)
- Proxy status: Proxied ✅
- Click "Save"
This allows all subdomains (*.yourplatform.com) to work automatically.
Custom Domains: The Magic Part
This is where things get exciting. Custom domains let your users brand their spaces with their own domains while you handle all the technical complexity.
How Custom Domains Work
Here's the complete flow:
DNS Records Explained
CNAME Record (Traffic Routing)
Type: CNAME
Name: clientdomain.com (or @)
Value: yourplatform.com
TTL: 3600
This routes traffic from clientdomain.com to your Cloudflare zone. When someone visits clientdomain.com, their DNS resolver follows the CNAME to yourplatform.com, which points to your Workers app.
TXT Records (SSL Verification)
Cloudflare provides TXT records for domain validation:
Type: TXT
Name: _acme-challenge.clientdomain.com
Value: [generated-by-cloudflare]
Why TXT records?
- Certificate validation: Proves you own the domain for SSL certificates
- Hostname pre-validation: Ensures the domain exists before adding it
DCV Delegation (Advanced)
For faster verification and automatic certificate renewal:
Type: CNAME
Name: _acme-challenge.clientdomain.com
Value: clientdomain.com.cf08b1ef9b3fff4e.dcv.cloudflare.com
This delegates ACME challenges to Cloudflare, allowing automatic certificate renewal without manual intervention.
Implementation Walkthrough
Adding a Domain (Server Action)
codeexport async function addCustomDomainAction(formData: FormData) { const subdomain = formData.get('subdomain') as string; const customDomain = formData.get('customDomain') as string; // 1. Validate domain format const normalizedDomain = normalizeDomain(customDomain); if (!isValidDomain(normalizedDomain)) { throw new Error('Please enter a valid domain name'); } // 2. Check if domain is already used const existingMapping = await getSubdomainByCustomDomain(normalizedDomain); if (existingMapping && existingMapping.subdomain !== subdomain) { throw new Error('This domain is already in use by another subdomain'); } try { // 3. Add to Cloudflare and store mapping in parallel const [cloudflareResult] = await Promise.all([ addCustomHostnameToCloudflare(normalizedDomain), setCustomDomain(subdomain, normalizedDomain) ]); if (!cloudflareResult.success) { throw new Error(cloudflareResult.error || 'Failed to add domain to Cloudflare'); } // 4. Cache DNS records for immediate access if (cloudflareResult.txtRecords && cloudflareResult.txtRecords.length > 0) { await redis.setex(`dns-records:${normalizedDomain}`, 3600, JSON.stringify({ txtRecords: cloudflareResult.txtRecords, timestamp: Date.now() })); } // 5. Return DNS configuration details return { success: true, domain: normalizedDomain, dcvDelegation: cloudflareResult.dcvDelegation, txtRecords: cloudflareResult.txtRecords, verificationRequired: cloudflareResult.verificationRequired }; } catch (error) { throw new Error('Failed to add custom domain. Please try again.'); }}
This action:
- Validates the domain format
- Checks for conflicts with existing domains
- Calls Cloudflare API and stores mapping in parallel
- Caches DNS records for faster access
- Returns instructions for the user
Verification Flow
codeexport async function verifyCustomDomainAction(formData: FormData) { const customDomain = formData.get('customDomain') as string; const normalizedDomain = normalizeDomain(customDomain); try { // Get verification status from Cloudflare const verification = await verifyCustomHostnameCloudflare(normalizedDomain); // Update verification status in Redis const customDomainData = await getSubdomainByCustomDomain(normalizedDomain); if (customDomainData) { await redis.set(`custom-domain:${normalizedDomain}`, { ...customDomainData, verified: verification.verified }); } return verification; } catch (error) { throw new Error('Failed to verify domain. Please try again.'); }}
User Experience
Here's what users see when adding a custom domain:
- Navigate to client.yourplatform.com/settings
- Enter custom domain clientdomain.com
- Click "Add Domain"
- See DNS instructions:
Add these DNS records to your domain: CNAME Record: Name: clientdomain.com Value: yourplatform.com TXT Record (for SSL): Name: _acme-challenge.clientdomain.com Value: cf-ssl-verification=abc123... - Add records to their DNS provider
- Click "Verify Domain"
- Success! Custom domain is live with SSL certificate
Deployment to Cloudflare
Build & Deploy
code# Build with OpenNextpnpm buildnpx opennextjs-cloudflare build# Deploy to productionnpx opennextjs-cloudflare deploy --env production# Or use the shortcutpnpm deploy
What Happens During Deployment
- Next.js Build: Creates optimized production build (.next/)
- OpenNext Conversion: Converts Next.js app to Workers-compatible format (.open-next/)
- Wrangler Deploy: Uploads to Cloudflare Workers
- Route Configuration: Sets up routing rules automatically
Verify Deployment
codenode scripts/verify-production.js
This script checks:
- Environment variables are set
- Cloudflare API credentials work
- Routes are configured correctly
Test URLs
After deployment, test these URLs:
- Main app: https://yourplatform.com
- Worker URL: https://platforms-cf-worker.[account].workers.dev
- Subdomain: https://client.yourplatform.com
- Custom domain: https://clientdomain.com (if configured)
Testing & Troubleshooting
Common Issues
1. TXT Records Not Showing
Symptoms: DNS instructions don't appear in settings page
Solutions:
code# Run diagnostic scriptnode scripts/diagnose-dns-txt.js# Check API token permissions# Verify environment variables in wrangler.toml
Check:
- API token has Zone:Custom Hostnames:Edit permission
- CLOUDFLARE_ZONE_ID is correct
- Zone is active in Cloudflare
2. Subdomains Not Working Locally
Symptoms: client.localhost:3000 doesn't work
Solutions:
- Use client.localhost:3000 format (not client.localhost)
- Check browser developer tools for network errors
- On macOS/Linux, add to /etc/hosts if needed:
127.0.0.1 client.localhost
3. Custom Domain Verification Fails
Symptoms: Domain shows "pending verification" forever
Solutions:
code# Check DNS propagationdig CNAME clientdomain.comdig TXT _acme-challenge.clientdomain.com# Wait 5-10 minutes for DNS propagation# Ensure TXT records are exactly as provided
Common mistakes:
- Wrong TXT record value (copy-paste errors)
- DNS provider caching old records
- Missing _acme-challenge prefix
4. SSL Certificate Pending
Symptoms: HTTPS shows "not secure" or certificate errors
Solutions:
- Ensure TXT records are correct
- Try DCV delegation for faster validation
- Check Cloudflare dashboard for certificate status
Helper Scripts
The repository includes several diagnostic scripts:
- scripts/setup-cloudflare-env.js - Configure Cloudflare variables
- scripts/diagnose-dns-txt.js - Debug DNS issues
- scripts/verify-production.js - Check deployment status
- scripts/test-cloudflare-api.js - Test API connectivity
What You've Built
Congratulations! You now have a production-ready multi-tenancy platform with:
✅ Subdomain routing with Next.js middleware
✅ Custom domains with automatic SSL via Cloudflare for SaaS
✅ Global edge deployment on Cloudflare Workers
✅ Redis-based tenant data storage
✅ Admin interface for managing tenants
✅ Domain verification workflow
Next Steps
Enhance the platform:
- Add custom branding per tenant (logos, colors, themes)
- Implement analytics per subdomain
- Add domain verification webhooks for real-time updates
- Customize 404 pages per tenant
- Add tenant-specific API endpoints
Scale further:
- Implement tenant isolation at the database level
- Add multi-region Redis for better performance
- Set up monitoring and alerting
- Add automated backups
Resources
- GitHub Repository - Complete source code
- Cloudflare for SaaS Documentation - Official docs
- OpenNext Documentation - Next.js → Workers adapter
- Upstash Redis - Serverless Redis
This guide covers the complete setup for a production-ready multi-tenancy platform. The custom domain feature is particularly powerful—it's what makes platforms like Notion and Webflow feel seamless to their users. With Cloudflare for SaaS handling SSL certificates and domain verification, you can focus on building features instead of managing infrastructure.