Building a Multi-City Production Portal with Next.js 14: Architecture, Infrastructure, and SEO Strategy

This post covers the technical implementation of rickdrakeproductions.com, a multi-city portal for a production coordination business. The project required domain registration, monorepo setup, dynamic routing for city-specific content, and CloudFront distribution configuration. Here's how we built it.

The Architecture Decision: Why Subdomains vs. Subdirectories

The initial architectural question: should each city be served via subdomains (e.g., san-diego.rickdrakeproductions.com) or subdirectories (e.g., rickdrakeproductions.com/san-diego)? For SEO and operational simplicity, we chose subdirectories as the primary pattern, with the flexibility to add subdomains later if individual city brands require it.

Why subdirectories first?

  • Link equity consolidation: All SEO signals flow to the root domain rather than splitting across subdomains
  • Shared infrastructure: Single SSL certificate, single CloudFront distribution, unified analytics
  • Referral model compatibility: If Rick doesn't adopt the service, the portal becomes a referral capture mechanism—subdirectories make client handoff cleaner
  • Simpler deployment: One build artifact serves all cities rather than managing separate builds per subdomain

Domain Registration and Route53 Setup

We registered rickdrakeproductions.com via AWS Route53 rather than GoDaddy, despite having GoDaddy API credentials available. Here's why:

  • Unified AWS account: DNS, certificates, CloudFront, and Lambda all live in the same account—no cross-service credential management
  • Infrastructure-as-code: Route53 records can be managed via Terraform or CloudFormation; GoDaddy requires manual API calls or third-party tools
  • Privacy protection included: Route53 registration includes WHOIS privacy at no extra cost

The registration command checked existing contact information from other Route53-registered domains (specifically 86from.com) to pre-populate the registrant details, minimizing manual entry and reducing registrant data exposure across multiple services.

Monorepo and Build Configuration

Project structure:

rickdrakeproductions.com/
├── pnpm-workspace.yaml
├── package.json
└── apps/
    └── web/
        ├── next.config.ts
        ├── package.json
        └── src/
            ├── app/
            │   ├── globals.css
            │   ├── layout.tsx
            │   ├── page.tsx
            │   └── [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
            └── lib/
                ├── types.ts
                ├── cities.ts
                └── content.ts

We used pnpm workspaces instead of npm or yarn because:

  • Stricter dependency isolation prevents phantom dependencies
  • Faster install times via content-addressable storage
  • Disk efficiency—dependencies deduplicated across workspaces

Dynamic Routing with Catch-All Parameters

The [city] directory uses Next.js 14's dynamic segments. The routing strategy:

  • / → Root homepage listing all cities
  • /[city]/ → City-specific landing page (e.g., /san-diego/)
  • /[city]/services → City services page
  • /[city]/fleet → City vehicle fleet listing
  • /[city]/fleet/[vehicle] → Individual vehicle detail page
  • /[city]/contact, /about, /locations, /portfolio → City-scoped pages

The src/lib/cities.ts file serves as the source of truth for available cities and their metadata:

export const CITIES = {
  'san-diego': { name: 'San Diego', slug: 'san-diego', ... },
  'las-vegas': { name: 'Las Vegas', slug: 'las-vegas', ... },
  // Phoenix, Palm Springs, LA added as they launch
}

export function getCityBySlug(slug: string) {
  return CITIES[slug] || null
}

This approach allows dynamic page generation while maintaining type safety. The layout component at src/app/[city]/layout.tsx wraps all city-scoped pages and handles city-specific metadata (title, description, OG tags).

Tailwind CSS 4 and lightningcss Native Binary

The build encountered a native dependency issue early: Tailwind CSS 4 uses lightningcss, which has optional native binaries for each platform. On macOS (darwin-x64), the npm install process doesn't automatically fetch platform-specific binaries if the main package manager is offline or if optional dependencies are skipped.

Solution:

# Install the darwin-x64 native binary explicitly
npm install lightningcss-darwin-x64 --save-optional

# Or use pnpm with optional dependency support
pnpm install --optional

This matters because compiling lightningcss from source during the Next.js build is significantly slower than using precompiled binaries. For production builds, we ensure the correct platform binary is installed before build time.

Infrastructure: CloudFront, ACM, and SSL

While rickdrakeproductions.com is new, the infrastructure patterns learned from sibling projects (adamcherrycomics.com, dangerouscentaur.com) informed the setup:

  • CloudFront distribution: Edge caching for static assets, regional failover for origin
  • ACM certificate: Wildcard cert covering *.rickdrakeproductions.com for future subdomain flexibility; DNS validation via Route53
  • S3 origin: Static site export option if needed; dynamic rendering via Lambda@Edge for city-specific headers/redirects

The distribution configuration (CloudFront dist ID: pending) enables:

  • Automatic gzip/brotli compression for text assets
  • HTTP/2 push for critical resources
  • Cache invalidation triggered by deployment pipelines
  • Regional failover if the primary origin becomes unavailable

Content and Type Safety

The src/lib/types.ts file defines the shape of all city, service, vehicle, and portfolio data:

export interface City {
  slug: string
  name: string
  description: string
  // city-specific metadata
}

export interface Vehicle {
  id: string
  name: string
  year