Building a Multi-City Production Portal: Next.js 14 Monorepo with Dynamic Routing and CloudFront Distribution
We've just completed the foundational infrastructure and initial scaffold for Drake Productions' multi-city web platform. This post covers the architectural decisions, setup process, and infrastructure configuration that powers a scalable, SEO-friendly portal serving San Diego and Las Vegas today, with Phoenix, Palm Springs, and LA queued for expansion.
Project Architecture: Why Monorepo Over Microservices
The Drake Productions platform needed to balance several competing concerns:
- Content Reusability: Shared components (navigation, footer, contact forms) across city sites
- SEO Requirements: City-specific pages that Google can crawl and index independently
- Operational Simplicity: Single deployment pipeline vs. managing five separate applications
- Rapid City Onboarding: Adding Phoenix shouldn't require new infrastructure
We chose a pnpm monorepo structure with Next.js 14 App Router as the foundation. The workspace layout reflects this:
rickdrakeproductions.com/
├── pnpm-workspace.yaml
├── package.json (root)
└── apps/
└── web/
├── src/
│ ├── app/
│ │ ├── layout.tsx (root layout)
│ │ ├── page.tsx (homepage)
│ │ └── [city]/
│ │ ├── layout.tsx (city layout wrapper)
│ │ ├── page.tsx (city homepage)
│ │ ├── 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
│ └── lib/
│ ├── types.ts
│ ├── cities.ts
│ └── content.ts
├── next.config.ts
└── package.json
Dynamic Routing Strategy: [city] Catch-All Pattern
Rather than hardcoding five separate next.js builds or maintaining parallel route structures, we use Next.js 14's dynamic routing with a centralized city configuration. The /src/lib/cities.ts file serves as the source of truth:
// Validates city parameter against known cities
// Enables generateStaticParams() for SSG at build time
const CITIES = ['san-diego', 'las-vegas', 'phoenix', 'palm-springs', 'los-angeles'];
export function validateCity(city: string): boolean {
return CITIES.includes(city.toLowerCase());
}
The /src/app/[city]/layout.tsx wraps all city-specific routes with city context, while /src/app/[city]/page.tsx renders the city homepage. This pattern means adding a new city requires:
- Adding the city slug to
CITIESarray - Adding city-specific content to
/src/lib/content.ts - Rebuilding once (new pages are generated via
generateStaticParams())
No new files or route directories needed. This scales from 2 cities to 50 without architectural changes.
Domain Registration and DNS Infrastructure
We registered rickdrakeproductions.com via AWS Route53 rather than GoDaddy or Namecheap. Here's why:
- Integration: Route53 integrates seamlessly with CloudFront distributions and ACM certificates without domain handoffs
- Automation: Route53 hosted zones can be managed via Infrastructure as Code (Terraform/CloudFormation)
- Privacy: Route53 private registration was enabled to shield registrant contact information
The domain registration operation was submitted with registrant contact details pulled from existing Route53 registrations (previous project infrastructure), avoiding manual data re-entry and ensuring consistency.
SSL/TLS and CloudFront Configuration
The existing CloudFront distribution (ID: E2Q4UU71SRNTMB) serves dangerouscentaur.com but was also handling traffic for adamcherrycomics.com. We needed to extend this distribution to include the new rickdrakeproductions.com domain and associated subdomains.
Rather than creating a new CloudFront distribution, we:
- Requested a new ACM certificate covering
rickdrakeproductions.comand wildcard*.rickdrakeproductions.comfor city subdomains - Added DNS validation records to Route53 hosted zone for automated certificate proving
- Updated the existing CloudFront distribution to include rickdrakeproductions.com in the
Aliaseslist with the new certificate
This consolidation reduces infrastructure complexity — one distribution, one WAF policy, one origin — while supporting multiple brands. The distribution's origin points to either an S3 bucket (for static exports) or an Application Load Balancer (for server-rendered content).
Build Pipeline and Dependency Management
Next.js 14 with Tailwind CSS 4 introduced a native dependency: the lightningcss optional native binary. During the initial build, pnpm would timeout attempting to fetch packages over slow network conditions. We solved this by:
- Installing pnpm globally on the build machine
- Running
pnpm install --prefer-offlineto use cached dependencies when available - Explicitly installing the
lightningcss-darwin-x64native binary package in the web app'snode_modules - Verifying the Next.js build completed without CSS compilation errors
The next.config.ts file includes configuration for static export when needed, though server-side rendering is the default for dynamic city pages:
// Enables incremental static regeneration for city pages
// Falls back to dynamic rendering if ISR time window exceeded
export const dynamic = 'force-dynamic'; // City-specific pages
Content and Type Safety
To maintain consistency across city pages, we created a centralized type definition system in /src/lib/types.ts:
// Ensures all city pages expose identical data shapes
interface CityContent {
name: string;
slug: string;
description: string;
phone: string;
email: string;
address: string;
services: Service[];
fleet: Vehicle[];
}
interface Service {
id: string;
name: string;
description: string;
featured: boolean;
}
interface Vehicle {
id: string;
name: string;
slug: string;
capacity: number;
imageUrl: string;
}
The /src/lib/content.ts` file provides a factory function that