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:

The solution? Next.js + OpenNext + Cloudflare Workers + Cloudflare for SaaS.

In this guide, you'll build a production-ready platform where users can:

Let's dive in.


Architecture Overview

Here's how the magic works:

Architecture Overview

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:

Technology Stack


Getting Started: Clone & Setup

Prerequisites

Step 1: Clone the Repository

code
git 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

code
pnpm dev

Access your app:


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

code
function 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:

  1. Local development: client.localhost:3000client
  2. Production subdomains: client.yourplatform.comclient
  3. Preview deployments: client---branch.pages.devclient

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:

  1. Look up the domain in Redis
  2. Find which subdomain it maps to
  3. Rewrite the request to /s/subdomain
  4. 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

code
export 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:

  1. Validates the domain format
  2. Calls Cloudflare's Custom Hostnames API
  3. Returns DNS records needed for verification
  4. Generates DCV delegation for automatic certificate renewal

Domain Verification

code
export 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

  1. Go to Cloudflare Dashboard
  2. Click "Create Token"
  3. Use "Custom token" template
  4. Set permissions:
    • Zone:Zone:Read
    • Zone:Custom Hostnames:Edit
  5. Zone Resources: IncludeSpecific zone → Select your domain
  6. Click "Continue to summary""Create Token"
  7. Copy the token (you won't see it again!)

Step 2: Get Zone ID & Account ID

  1. Go to your domain in Cloudflare Dashboard
  2. Scroll to "API" section (right sidebar)
  3. Copy Zone ID and Account ID

Configure wrangler.toml

This file tells Cloudflare Workers how to deploy your app:

code
name = "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:

DNS Setup in Cloudflare

Wildcard DNS Record

  1. Go to DNSRecords
  2. Click "Add record"
  3. Configure:
    • Type: A or CNAME
    • Name: *
    • Content: @ (or your Workers domain)
    • Proxy status: Proxied ✅
  4. 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:

Custom Domains 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?

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)

code
export 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:

  1. Validates the domain format
  2. Checks for conflicts with existing domains
  3. Calls Cloudflare API and stores mapping in parallel
  4. Caches DNS records for faster access
  5. Returns instructions for the user

Verification Flow

code
export 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:

  1. Navigate to client.yourplatform.com/settings
  2. Enter custom domain clientdomain.com
  3. Click "Add Domain"
  4. 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...
    
  5. Add records to their DNS provider
  6. Click "Verify Domain"
  7. 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

  1. Next.js Build: Creates optimized production build (.next/)
  2. OpenNext Conversion: Converts Next.js app to Workers-compatible format (.open-next/)
  3. Wrangler Deploy: Uploads to Cloudflare Workers
  4. Route Configuration: Sets up routing rules automatically

Verify Deployment

code
node scripts/verify-production.js

This script checks:

Test URLs

After deployment, test these URLs:


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:

2. Subdomains Not Working Locally

Symptoms: client.localhost:3000 doesn't work

Solutions:

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:

4. SSL Certificate Pending

Symptoms: HTTPS shows "not secure" or certificate errors

Solutions:

Helper Scripts

The repository includes several diagnostic scripts:


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:

Scale further:

Resources


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.