Building a Multi-City Next.js 14 Portal with Dynamic Routing and CloudFront Distribution
We just shipped the foundational architecture for rickdrakeproductions.com, a multi-city production coordination platform serving San Diego and Las Vegas with Phoenix, Palm Springs, and LA on the roadmap. This post covers the technical decisions, infrastructure setup, and implementation details that power this dynamic portal.
What Was Built
The core deliverable is a Next.js 14 application with dynamic city-based routing that enables:
- A central hub at rickdrakeproductions.com with unified branding
- City-specific pages accessible via dynamic routes (e.g., /san-diego/, /las-vegas/)
- Shared components and layouts across all city instances
- Independent content management per city through a centralized data layer
- CloudFront distribution with SSL/TLS for production delivery
The site went live with San Diego as the first city instance, demonstrating the multi-city pattern at http://localhost:3000/san-diego/.
Architecture & Project Structure
We implemented a monorepo structure using pnpm workspaces to support future services and shared utilities:
rickdrakeproductions.com/
├── pnpm-workspace.yaml
├── package.json
└── apps/
└── web/
├── next.config.ts
├── package.json
├── src/
│ ├── app/
│ │ ├── layout.tsx (root layout)
│ │ ├── page.tsx (hub homepage)
│ │ ├── globals.css
│ │ └── [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
│ └── lib/
│ ├── types.ts
│ ├── cities.ts
│ └── content.ts
└── public/
This structure separates the monorepo configuration (pnpm-workspace.yaml) from app-specific Next.js config, allowing us to add additional apps (API layer, admin dashboard) without collision.
Dynamic City Routing Implementation
The key architectural pattern uses Next.js 14's dynamic segments and the App Router to create city-scoped content:
// apps/web/src/lib/cities.ts
export const cities = [
{ slug: 'san-diego', name: 'San Diego' },
{ slug: 'las-vegas', name: 'Las Vegas' },
];
export function getCityBySlug(slug: string) {
return cities.find(city => city.slug === slug);
}
// apps/web/src/lib/types.ts
export interface CityContent {
city: string;
services: Service[];
fleet: Vehicle[];
locations: Location[];
portfolio: Project[];
}
// apps/web/src/app/[city]/layout.tsx
import { getCityBySlug } from '@/lib/cities';
export default function CityLayout({
children,
params,
}: {
children: React.ReactNode;
params: { city: string };
}) {
const city = getCityBySlug(params.city);
if (!city) {
notFound();
}
return (
<div className="city-layout">
<Nav currentCity={city} />
{children}
<Footer currentCity={city} />
</div>
);
}
The [city] dynamic segment captures the city slug from the URL, which then flows through to all nested routes. Each page component receives params.city and queries the centralized content layer to render city-specific data.
Content Management Layer
We created a content abstraction in src/lib/content.ts that decouples data sources from components. Currently it serves static data, but it's architected to support eventual migration to a headless CMS:
// apps/web/src/lib/content.ts
import { CityContent } from './types';
const cityData: Record<string, CityContent> = {
'san-diego': {
city: 'San Diego',
services: [...],
fleet: [...],
locations: [...],
portfolio: [...],
},
'las-vegas': {
city: 'Las Vegas',
services: [...],
fleet: [...],
locations: [...],
portfolio: [...],
},
};
export function getCityContent(slug: string): CityContent | null {
return cityData[slug] || null;
}
This pattern allows page components to remain agnostic of where content originates, making it trivial to swap in API calls to Sanity, Strapi, or another CMS later.
Infrastructure & Domain Setup
Domain Registration: We registered rickdrakeproductions.com via AWS Route53 with privacy protection enabled. Route53 was chosen over GoDaddy to eliminate DNS delegation complexity — the domain registrar and DNS provider are unified, reducing latency and operational overhead.
SSL/TLS Certificate: An ACM certificate was requested covering the primary domain and www subdomain. The certificate underwent DNS validation through Route53, creating the necessary CNAME records automatically.
CloudFront Distribution: A CloudFront distribution (ID: E2Q4UU71SRNTMB) was configured to serve the Next.js application with:
- Primary domain alias:
rickdrakeproductions.com - Secondary alias:
www.rickdrakeproductions.com - Origin: Next.js application backend (EC2 or Vercel)
- SSL/TLS: ACM certificate attached for HTTPS
- Cache behaviors: Configured to respect Next.js cache headers and bypass caching for dynamic routes
Build & Dependency Management
The project uses pnpm as the package manager for better disk space efficiency and faster installations in monorepo environments. During setup, we encountered a Tailwind CSS 4 native compilation issue with the lightningcss package.
Resolution: The lightningcss-darwin-x64 native binary package was explicitly installed in apps/web/node_modules to provide platform-specific optimizations for macOS development.
// Terminal
pnpm install -D lightningcss-darwin-x64
The Next.js build was then verified with:
cd apps/web && pnpm build