WebZum Logo
WebZum

From Zero to Website Hero

Sign InSign Up
Back to Blog
infrastructurecloudfronts3nextjsstartup

How We Serve 1,000+ Generated Websites on Custom Subdomains (Without a Database Query)

WebZum Team•August 12, 2025•8 min read
How We Serve 1,000+ Generated Websites on Custom Subdomains (Without a Database Query)

How We Serve 1,000+ Generated Websites on Custom Subdomains (Without a Database Query)

TL;DR: We built an infrastructure that serves generated websites on custom subdomains (business.webzum.com) using S3 for storage, Next.js middleware for routing, and CloudFront for delivery. Zero database queries per request. Handles 1,000+ websites with 50ms average response time.

The Problem: Generated Websites Need URLs

We generate complete websites with AI. But where do they live?

Options we considered:

  1. Single domain with paths: webzum.com/business-name (ugly, not professional)
  2. Custom domains: business.com (requires DNS setup, slow)
  3. Subdomains: business.webzum.com (perfect!)

The challenge: How do you serve 1,000+ different websites on 1,000+ different subdomains?

Traditional approaches:

  • Database routing: Query DB for every request (slow, expensive)
  • Separate deployments: Deploy each website separately (complex, doesn’t scale)
  • Reverse proxy: Nginx/HAProxy with config files (brittle, manual)

We needed something better: Dynamic subdomain routing without database queries.

The Insight: S3 + Middleware + CloudFront = Magic

The breakthrough came when we realized: We don’t need a database if the URL tells us where the files are.

Bad: Request → Query DB → Find files → Serve Good: Request → Parse subdomain → Fetch from S3 → Serve

The difference? No database = faster, simpler, more reliable.

How It Works: The Technical Architecture

1. File Storage Structure

Every generated website is stored in S3 with a predictable path:

s3://webzum-generated-sites/
  ├── business-name-123/
  │   ├── latest/
  │   │   ├── index.html
  │   │   ├── about.html
  │   │   ├── contact.html
  │   │   ├── assets/
  │   │   │   ├── logo.png
  │   │   │   ├── hero.jpg
  │   │   │   └── styles.css
  │   ├── v1/
  │   │   └── ... (same structure)
  │   └── v2/
  │       └── ... (same structure)
  └── another-business-456/
      └── ... (same structure)

Key decision: Use businessId in the path, not domain name. This makes routing deterministic.

2. Next.js Middleware for Subdomain Routing

We use Next.js middleware to intercept subdomain requests:

// src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export async function middleware(req: NextRequest) {
  const hostname = req.headers.get('host') || '';
  
  // Check if it's a subdomain request
  if (hostname.endsWith('.webzum.com') && hostname !== 'webzum.com') {
    // Extract subdomain
    const subdomain = hostname.replace('.webzum.com', '');
    
    // Extract path
    const path = req.nextUrl.pathname;
    
    // Rewrite to subdomain handler
    const url = req.nextUrl.clone();
    url.pathname = `/api/subdomain`;
    url.searchParams.set('subdomain', subdomain);
    url.searchParams.set('path', path);
    
    return NextResponse.rewrite(url);
  }
  
  // Not a subdomain, continue normally
  return NextResponse.next();
}

export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

3. Subdomain Handler (Serves Files from S3)

The handler fetches files from S3 and serves them:

// src/app/api/subdomain/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

const s3Client = new S3Client({ region: 'us-east-1' });

export async function GET(req: NextRequest) {
  const subdomain = req.nextUrl.searchParams.get('subdomain');
  const path = req.nextUrl.searchParams.get('path') || '/';
  
  // Normalize path
  let filePath = path === '/' ? '/index.html' : path;
  
  // Remove leading slash
  if (filePath.startsWith('/')) {
    filePath = filePath.substring(1);
  }
  
  // Construct S3 key
  const s3Key = `${subdomain}/latest/${filePath}`;
  
  try {
    // Fetch from S3
    const command = new GetObjectCommand({
      Bucket: 'webzum-generated-sites',
      Key: s3Key
    });
    
    const response = await s3Client.send(command);
    
    // Stream response
    const body = await response.Body.transformToByteArray();
    
    // Determine content type
    const contentType = getContentType(filePath);
    
    return new NextResponse(body, {
      status: 200,
      headers: {
        'Content-Type': contentType,
        'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
      }
    });
    
  } catch (error) {
    if (error.name === 'NoSuchKey') {
      // File not found, try index.html (for SPA routing)
      if (!filePath.endsWith('.html')) {
        return fetchFromS3(`${subdomain}/latest/index.html`);
      }
      
      return new NextResponse('Not Found', { status: 404 });
    }
    
    console.error('S3 fetch error:', error);
    return new NextResponse('Internal Server Error', { status: 500 });
  }
}

function getContentType(filePath: string): string {
  const ext = filePath.split('.').pop()?.toLowerCase();
  
  const mimeTypes: Record<string, string> = {
    'html': 'text/html',
    'css': 'text/css',
    'js': 'application/javascript',
    'json': 'application/json',
    'png': 'image/png',
    'jpg': 'image/jpeg',
    'jpeg': 'image/jpeg',
    'gif': 'image/gif',
    'svg': 'image/svg+xml',
    'ico': 'image/x-icon',
    'woff': 'font/woff',
    'woff2': 'font/woff2',
    'ttf': 'font/ttf',
    'eot': 'application/vnd.ms-fontobject'
  };
  
  return mimeTypes[ext] || 'application/octet-stream';
}

4. CloudFront for Global Delivery

We put CloudFront in front of everything for speed:

// infrastructure/lib/cloudfront-subdomain-stack.ts
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as s3 from 'aws-cdk-lib/aws-s3';

export class CloudFrontSubdomainStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    
    // S3 bucket for generated sites
    const bucket = new s3.Bucket(this, 'GeneratedSitesBucket', {
      bucketName: 'webzum-generated-sites',
      publicReadAccess: false,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL
    });
    
    // Origin Access Identity (OAI) for CloudFront to access S3
    const oai = new cloudfront.OriginAccessIdentity(this, 'OAI');
    bucket.grantRead(oai);
    
    // CloudFront distribution
    const distribution = new cloudfront.Distribution(this, 'SubdomainDistribution', {
      defaultBehavior: {
        origin: new origins.S3Origin(bucket, {
          originAccessIdentity: oai
        }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: new cloudfront.CachePolicy(this, 'CachePolicy', {
          minTtl: Duration.seconds(0),
          maxTtl: Duration.days(365),
          defaultTtl: Duration.hours(1),
          queryStringBehavior: cloudfront.CacheQueryStringBehavior.all()
        })
      },
      domainNames: ['*.webzum.com'],
      certificate: certificate, // ACM certificate for *.webzum.com
      errorResponses: [
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: '/index.html', // SPA fallback
          ttl: Duration.seconds(0)
        }
      ]
    });
  }
}

5. Wildcard DNS Configuration

Configure DNS to point all subdomains to CloudFront:

# Route53 DNS records
*.webzum.com  CNAME  d1234567890.cloudfront.net

Result: Any subdomain (*.webzum.com) routes to CloudFront → S3.

6. Version Management

Each website can have multiple versions:

// Serve specific version
const s3Key = `${subdomain}/${version}/${filePath}`;

// Serve latest version (default)
const s3Key = `${subdomain}/latest/${filePath}`;

// Serve preview version (for editing)
const s3Key = `${subdomain}/preview/${filePath}`;

URL patterns:

  • business.webzum.com → latest version
  • business.webzum.com?v=v2 → specific version
  • business.webzum.com?preview=true → preview version

The Challenges We Solved

Challenge 1: Cold Start Performance

Problem: First request to a subdomain is slow (S3 fetch + CloudFront cache miss)

Solution: Pre-warm CloudFront cache after generation

async function deployToLatest(businessId: string, version: string) {
  // Copy version to 'latest'
  await copyS3Directory(
    `${businessId}/${version}`,
    `${businessId}/latest`
  );
  
  // Invalidate CloudFront cache
  await cloudfront.createInvalidation({
    DistributionId: DISTRIBUTION_ID,
    InvalidationBatch: {
      CallerReference: `${businessId}-${Date.now()}`,
      Paths: {
        Quantity: 1,
        Items: [`/${businessId}/*`]
      }
    }
  });
  
  // Pre-warm cache by fetching key pages
  await Promise.all([
    fetch(`https://${businessId}.webzum.com/`),
    fetch(`https://${businessId}.webzum.com/about`),
    fetch(`https://${businessId}.webzum.com/contact`)
  ]);
}

Challenge 2: Asset Path Resolution

Problem: HTML references assets with relative paths (./logo.png), but S3 needs full paths

Solution: Rewrite asset paths during generation

function generateHTML(content: string, assets: Asset[]): string {
  let html = content;
  
  // Rewrite asset references
  for (const asset of assets) {
    const relativePath = `./${asset.filename}`;
    const absolutePath = `/assets/${asset.filename}`;
    
    html = html.replace(
      new RegExp(relativePath, 'g'),
      absolutePath
    );
  }
  
  return html;
}

Challenge 3: 404 Handling

Problem: Subdomain doesn’t exist, but CloudFront returns generic 404

Solution: Custom 404 page per subdomain

export async function GET(req: NextRequest) {
  const subdomain = req.nextUrl.searchParams.get('subdomain');
  const path = req.nextUrl.searchParams.get('path');
  
  try {
    // Try to fetch file
    return await fetchFromS3(`${subdomain}/latest/${path}`);
  } catch (error) {
    if (error.name === 'NoSuchKey') {
      // Check if subdomain exists at all
      const subdomainExists = await checkSubdomainExists(subdomain);
      
      if (!subdomainExists) {
        // Subdomain doesn't exist
        return new NextResponse('Website not found', {
          status: 404,
          headers: {
            'Content-Type': 'text/html'
          }
        });
      }
      
      // Subdomain exists, but file doesn't
      // Serve custom 404 page
      return await fetchFromS3(`${subdomain}/latest/404.html`);
    }
    
    throw error;
  }
}

async function checkSubdomainExists(subdomain: string): Promise<boolean> {
  try {
    // Check if index.html exists
    await s3Client.send(new HeadObjectCommand({
      Bucket: 'webzum-generated-sites',
      Key: `${subdomain}/latest/index.html`
    }));
    return true;
  } catch {
    return false;
  }
}

Challenge 4: SSL Certificates

Problem: Need SSL for *.webzum.com (wildcard certificate)

Solution: AWS Certificate Manager (ACM)

# Request wildcard certificate
aws acm request-certificate \
  --domain-name "*.webzum.com" \
  --validation-method DNS \
  --region us-east-1

Important: CloudFront requires certificates in us-east-1.

The Results: Fast, Scalable, Reliable

Performance:

  • First request (cold): 200ms
  • Cached request (warm): 50ms
  • CloudFront cache hit rate: 95%

Scale:

  • 1,000+ websites served
  • 10,000+ requests/day
  • 99.9% uptime

Cost:

  • S3 storage: $0.023/GB/month
  • CloudFront: $0.085/GB transferred
  • Total: ~$50/month for 1,000 websites

vs. Traditional hosting:

  • Vercel: $20/month per website = $20,000/month
  • Netlify: $19/month per website = $19,000/month
  • Our solution: $50/month total

Savings: $19,950/month 🎉

Why This Matters for Startups

Most startups use expensive hosting services. We learned:

Bad: Pay $20/month per website → unsustainable at scale Good: Build your own infrastructure → $0.05/month per website

The startup lesson: Infrastructure costs matter. S3 + CloudFront + middleware = powerful, cheap, scalable hosting.

Key Insights

  1. S3 is cheap: $0.023/GB/month vs $20/month per site
  2. CloudFront is fast: Global CDN with 95% cache hit rate
  3. Middleware is powerful: Next.js middleware handles complex routing
  4. No database needed: URL structure encodes all routing info

What’s Next

We’re exploring:

  • Custom domains: Let users use their own domains (business.com)
  • Edge functions: Run code at CloudFront edge locations
  • Real-time updates: WebSocket support for live editing
  • Analytics: Track page views, user behavior per subdomain

But the core insight remains: Own your infrastructure. Don’t rent it.


Try it yourself: Generate a website with WebZum, get a subdomain like business.webzum.com. It’s served from S3 via CloudFront—no database queries, just fast file delivery.

Building a multi-tenant app? Key takeaway: S3 + CloudFront + middleware = cheap, fast, scalable infrastructure. Don’t pay $20/month per tenant when you can pay $0.05.

The future of SaaS infrastructure isn’t expensive hosting—it’s smart architecture.

Ready to Build Your Website?

Join hundreds of businesses using WebZum to create professional websites in minutes, not weeks.

Get Started Free
Live in 5 minutesNo credit card required
Home•Free Tools•Blog•Directory•About•Agencies•Partners
FAQ•Privacy•Terms•© 2026 WebZum