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:
- Single domain with paths:
webzum.com/business-name(ugly, not professional) - Custom domains:
business.com(requires DNS setup, slow) - 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 versionbusiness.webzum.com?v=v2→ specific versionbusiness.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
- S3 is cheap: $0.023/GB/month vs $20/month per site
- CloudFront is fast: Global CDN with 95% cache hit rate
- Middleware is powerful: Next.js middleware handles complex routing
- 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.