The Queenof-Cities Factory Pattern: Building 12 Dynamic Sailing Websites from a Single Codebase
What Was Done
JADA's dream-city expansion required launching sailing charter microsites for 12 different US coastal markets—San Diego, Sausalito, Napa, Miami, Charleston, and others—without duplicating code or maintenance overhead. The solution: a factory pattern that generates fully-featured city-specific sites from a single source repository, deployed to a shared CloudFront distribution with per-city URL routing.
Each site includes:
- Customized branding and city-specific imagery (via Unsplash API)
- Dynamic pricing calculated from JADA's charter ledger
- City-specific contact info and crew rosters
- Responsive design optimized for mobile-first booking flows
- SEO-optimized metadata for local search ranking
Technical Architecture
Source Repository: ~/icloud-repos/sites/queenof-cities/
The codebase uses a parameterized build system:
sites/queenof-cities/
├── factory/ # Build orchestration
│ ├── generate.js # Parameterized site generator
│ ├── config.yml # City definitions (slug, coords, boat list, crew)
│ └── templates/ # React/HTML templates with {{interpolation}}
├── cities/ # City-specific overrides (minimal)
│ ├── sausalito/ # Boat roster, seasonal pricing
│ └── miami/
└── dist/ # Build output (6 copies: one per city)
Each city definition in config.yml specifies:
slug— URL path identifier (e.g., "sausalito")coords— Latitude/longitude for embedded mapboat_ids— Which JADA vessels operate therecrew_override— Local captain/mate assignments (optional)banner_search_term— Unsplash query for hero imageseason_pricing_map— Q1/Q2/Q3/Q4 rate multipliers
Build Process:
~/bin/generate-queenof-cities \
--config ~/icloud-repos/sites/queenof-cities/config.yml \
--template ~/icloud-repos/sites/queenof-cities/factory/templates/ \
--output ~/icloud-repos/sites/queenof-cities/dist/
This generates 12 standalone HTML/CSS/JS bundles (one per city), each fully self-contained with zero runtime dependencies. No city reads from a shared template at runtime.
Infrastructure & Deployment
S3 Bucket: dc-sites (same shared bucket as Dragon Bodyguards, Rady shell event templates)
CloudFront Distribution: E176JRMB92GPAJ
Route53 behavior:
queenofsandiego.com→ root index (city selector)sausalito.queenofsandiego.com→s3://dc-sites/queenof-cities/dist/sausalito/index.htmlmiami.queenofsandiego.com→s3://dc-sites/queenof-cities/dist/miami/index.html- Pattern repeats for all 12 slugs
Caching Strategy: CloudFront is configured with --invalidate on every build. Because the factory generates all 12 sites atomically, you invalidate once, not per-city:
aws cloudfront create-invalidation \
--distribution-id E176JRMB92GPAJ \
--paths "/queenof-cities/*"
This ensures fresh content across all cities immediately after generate-queenof-cities completes.
Key Decisions
Why Factory Over Headless CMS: No external dependency on WordPress, Contentful, or Strapi. All city data lives in version-controlled YAML. Deployments are deterministic: same input → same output, every time. No database queries during page load; every site is static HTML.
Why Atomic Generation: Building all 12 at once prevents partial rollouts where some cities see old content. A failed build leaves the previous dist/ untouched; a successful build updates all cities in lockstep.
Why Unsplash Instead of Manual Photography: Each city hero image is fetched via Unsplash's free API (no credentials needed, rate-limited at 50 req/hr). This avoids the maintenance burden of curating and uploading 12 different boat photos while still delivering city-specific visuals.
curl -s "https://api.unsplash.com/photos/random?query=sailboat+sausalito" \
| jq -r '.urls.regular' \
| xargs curl -o cities/sausalito/hero.jpg
Why DynamoDB Mirror for Crew Rosters: The canonical crew roster lives in Google Sheets (Master Roster sheet, gid=2143500117). A nightly Lambda mirrors it to DynamoDB. The factory reads DDB at build time (no auth needed, fast), not Sheets (requires OAuth token refresh). If Google auth dies during a build, the factory still succeeds with yesterday's roster data.
SEO & Compliance
Each generated city site includes:
<title>tag: "Luxury Sailboat Charters in [City] — JADA — The Queen of San Diego"<meta name="description">with city-specific keywords- OpenGraph tags for social sharing (city name + boat image)
- Structured data (schema.org Service listing with city coordinates)
- robots.txt generated per-city with no noindex directives
Each city's route gets a dedicated Google Business Profile entry for local search ranking. The factory generates a sitemap per city pointing to booking flows, reducing time for Googlebot to discover CTAs.
What's Next
The next wave of optimization:
- Dynamic Pricing API: Currently pricing is a config.yml multiplier. Evolve to a Lambda endpoint that reads real-time JADA ledger data and adjusts quotes per-city based on demand/seasonality.
- A/B Testing Framework: Generate variant hero images per city (Unsplash queries like "sailboat" vs. "luxury yacht") and measure click-through via UTM tags in generated links.
- Multi-Language Support: Use config.yml to define `lang` per city (es, fr, de) and generate localized sites without code changes.
- CI/CD Pipeline: Move the build into GitHub Actions: push to main → factory generates → S3 upload → CloudFront invalidate → health check (curl each city's index for 200 status).
This architecture has proven durable at the 12-city scale. The factory pattern's main win: one deployment tool, twelve live markets, zero runtime drift.