Building a Multi-City Production Portal: From Monolith to Scalable Next.js 14 Architecture
Rick Drake Productions needed a modern, scalable platform to power production coordination across multiple cities. This post documents the technical decisions and infrastructure setup for converting a dated WordPress site into a dynamic Next.js 14 application that can rapidly scale to new markets.
The Problem: Legacy WordPress, Future Growth
The existing sandiegoproductions.com ran on WordPress with Visual Composer—a 2016-era stack that was difficult to maintain and couldn't handle the planned expansion to Phoenix, Palm Springs, and LA. Rick needed:
- A hub domain (
rickdrakeproductions.com) that unified the brand - City-specific pages or subdomains for San Diego, Las Vegas, and future markets
- SEO-friendly URL structures for local search optimization
- Rapid deployment of new city sites without duplication
- A fallback referral business model if direct service expansion didn't materialize
Architecture Decision: Dynamic Routes over Static Subdomains
We chose dynamic route segments in Next.js 14 rather than separate subdomains or a traditional monorepo of separate apps. This decision was driven by several factors:
- SEO Consolidation: Dynamic routes allow city pages to inherit domain authority from the hub, while still being discoverable via city-specific keywords
- Maintenance: One codebase with parameterized city data beats maintaining separate domain configurations
- Scalability: Adding Phoenix or Palm Springs requires only adding city data to a centralized config, not spinning up new Next.js instances
- Analytics: Unified tracking across all cities in a single Google Analytics property
The alternative—subdomains like sandiego.rickdrakeproductions.com—would require separate SSL certificates, more complex DNS management, and scattered analytics. For a business with 5+ planned markets, dynamic routes won out.
Project Structure: Monorepo with pnpm Workspaces
We initialized a pnpm workspace to future-proof for additional apps (admin dashboard, API service, etc.):
rickdrakeproductions.com/
├── pnpm-workspace.yaml
├── package.json
└── apps/
└── web/
├── next.config.ts
├── package.json
├── tsconfig.json
├── tailwind.config.ts
└── src/
├── lib/
│ ├── types.ts
│ ├── cities.ts
└── content.ts
├── app/
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx (hub homepage)
│ └── [city]/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── services/page.tsx
│ ├── fleet/page.tsx
│ ├── fleet/[vehicle]/page.tsx
│ ├── contact/page.tsx
│ ├── about/page.tsx
│ ├── locations/page.tsx
│ └── portfolio/page.tsx
└── components/
└── layout/
├── Nav.tsx
└── Footer.tsx
The pnpm-workspace.yaml file declares the apps/web directory as a workspace member, allowing us to run pnpm install at the root and have dependencies installed in the correct isolation level.
City Data: Centralized, Typed Configuration
Rather than hardcoding city names into components, we created a single source of truth in apps/web/src/lib/cities.ts:
export const CITIES = [
{
slug: 'san-diego',
name: 'San Diego',
state: 'CA',
phoneDisplay: '(619) 555-0123',
serviceArea: 'San Diego County',
},
{
slug: 'las-vegas',
name: 'Las Vegas',
state: 'NV',
phoneDisplay: '(702) 555-0456',
serviceArea: 'Clark County, NV',
},
// Future cities: phoenix, palm-springs, los-angeles
];
This approach means:
- Adding Phoenix requires one new object in this array
- Route validation happens automatically via TypeScript
- We can fetch contact info from a CMS or database later without restructuring the app
- Dynamic sitemaps, canonical tags, and hreflang attributes derive from this config
Dynamic Routes: The [city] Segment
Next.js 14's [city] dynamic segment in apps/web/src/app/[city]/page.tsx handles requests like /san-diego/ and /las-vegas/. The layout wrapper in apps/web/src/app/[city]/layout.tsx injects city-specific metadata, navigation breadcrumbs, and contact information.
Each nested route like apps/web/src/app/[city]/services/page.tsx receives the city param and can display city-specific fleet, pricing, or availability without code duplication.
Infrastructure: Route53 + CloudFront + ACM
Domain registration and DNS were handled entirely within AWS:
- Route53 Domain Registration:
rickdrakeproductions.comwas registered via Route53 with privacy protection enabled. This avoids WHOIS scraping and keeps the domain locked within the AWS ecosystem. - CloudFront Distribution: A CloudFront distribution (ID:
E2Q4UU71SRNTMB) sits in front of the Next.js app on Vercel or self-hosted infrastructure, providing edge caching, compression, and DDoS protection. - ACM Certificate: A wildcard certificate for
*.rickdrakeproductions.comandwww.rickdrakeproductions.comwas issued via AWS Certificate Manager and attached to the CloudFront distribution. - DNS Aliases: Route53 aliases point both
rickdrakeproductions.comandwww.rickdrakeproductions.comto the CloudFront distribution.
Why this stack? CloudFront edge locations in 200+ cities mean city pages render with sub-100ms latency globally. If Rick expands to LA or Phoenix, clients in those regions get local-like performance out of the box.
Build & Deployment Challenges: lightningcss Native Binaries
Tailwind CSS 4 introduced a build-time dependency on lightningcss, which requires native binaries for different architectures. On macOS (Darwin x64), the build would fail until we explicitly installed:
npm install lightningcss-darwin-x64 --save-optional
This taught us a lesson about optional dependencies in monorepos: ensure platform-specific binaries are available before running next build. In CI/CD, we'll need conditional logic to install lightningcss-linux-x64