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 CITIES array
  • 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:

  1. Requested a new ACM certificate covering rickdrakeproductions.com and wildcard *.rickdrakeproductions.com for city subdomains
  2. Added DNS validation records to Route53 hosted zone for automated certificate proving
  3. Updated the existing CloudFront distribution to include rickdrakeproductions.com in the Aliases list 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-offline to use cached dependencies when available
  • Explicitly installing the lightningcss-darwin-x64 native binary package in the web app's node_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