Building a Multi-City Production Portal: Next.js 14 Architecture with Dynamic Routing and Monorepo Setup
We just completed the initial architecture and deployment infrastructure for rickdrakeproductions.com, a multi-city web portal serving production coordination companies across San Diego, Las Vegas, Phoenix, Palm Springs, and LA. Here's how we structured it and why.
Domain Registration and DNS Infrastructure
We registered rickdrakeproductions.com via AWS Route53 with privacy protection enabled. This decision prioritized infrastructure consolidation — since the entire stack runs on AWS (CloudFront, S3, Lambda), keeping domain management within Route53 eliminates vendor handoff friction and enables programmatic DNS updates for certificate validation and deployment automation.
The domain registration operation completed successfully with existing contact information from our Route53 account, reducing setup friction. All DNS records are now manageable through the AWS CLI and CloudFormation, keeping infrastructure-as-code patterns consistent across the stack.
Monorepo Structure and Package Management
We initialized a pnpm workspace at the repository root with this structure:
/Users/cb/Documents/repos/sites/rickdrakeproductions.com/
├── pnpm-workspace.yaml
├── package.json
└── apps/
└── web/
├── next.config.ts
├── src/
│ ├── app/
│ ├── components/
│ ├── lib/
│ └── styles/
└── package.json
We chose pnpm for three reasons: (1) workspace hoisting reduces node_modules duplication across monorepo packages, (2) strict dependency resolution catches accidental implicit dependencies early, and (3) pnpm's lock file format optimizes for CI/CD caching performance.
The workspace configuration in pnpm-workspace.yaml declares apps/* as managed packages, allowing shared dependencies to be resolved at the root level while maintaining package isolation for the web app.
Next.js 14 App Router with Dynamic City Routes
The web application scaffolds Next.js 14 with TypeScript, Tailwind CSS v4, and the App Router. The critical architectural decision was implementing city-based dynamic routing through Next.js segment conventions:
apps/web/src/app/
├── layout.tsx # Root layout, global styles
├── page.tsx # Homepage (rickdrakeproductions.com/)
├── globals.css
├── [city]/
│ ├── layout.tsx # City-specific layout
│ ├── page.tsx # City homepage (/san-diego/, /las-vegas/)
│ ├── services/
│ │ └── page.tsx # /[city]/services/
│ ├── fleet/
│ │ ├── page.tsx # /[city]/fleet/
│ │ └── [vehicle]/
│ │ └── page.tsx # /[city]/fleet/[vehicle]/
│ ├── contact/
│ │ └── page.tsx
│ ├── about/
│ │ └── page.tsx
│ ├── locations/
│ │ └── page.tsx
│ └── portfolio/
│ └── page.tsx
This structure provides three critical benefits from an SEO perspective:
- Path-based city segmentation: Google recognizes
/san-diego/and/las-vegas/as content signals for geographic relevance, improving local search rankings without requiring subdomains - Shared root domain authority: City pages inherit link equity from the central domain, concentrating SEO value rather than fragmenting it across subdomains
- Unified analytics: All traffic funnels through one Google Analytics property, making cross-city reporting and conversion tracking straightforward
Content and Configuration Layer
We created three utility modules in apps/web/src/lib/:
types.ts— TypeScript interfaces for cities, services, fleet vehicles, and location datacities.ts— Array of city objects with metadata (slug, display name, coordinates for map integration)content.ts— Content fetching and transformation layer (initially static, designed to swap in API calls or CMS data later)
This separation decouples content from presentation, allowing content updates without component refactoring. The types module ensures consistency across all city pages — the TypeScript compiler enforces that city-specific pages implement required fields.
The [city] layout reads the dynamic segment and loads city-specific data:
// apps/web/src/app/[city]/layout.tsx
export async function generateStaticParams() {
return cities.map((city) => ({
city: city.slug,
}));
}
Using generateStaticParams() enables static generation for all known cities, pre-rendering pages at build time. This provides CDN-friendly static HTML while maintaining the flexibility to add dynamic routes later.
Component Architecture
Navigation and footer components in apps/web/src/components/layout/ read city data dynamically, rendering links to city-specific pages and services. This avoids hardcoding navigation, making it trivial to add new cities without code changes.
Global styles in apps/web/src/app/globals.css establish design tokens and Tailwind configuration, ensuring visual consistency across all city pages.
Build Configuration
The apps/web/next.config.ts includes configuration for:
- Tailwind CSS v4 with lightningcss native binaries for faster builds
- Image optimization for production deployment
- Static export configuration (when ready for CloudFront distribution)
We encountered and resolved a native binary dependency issue: Tailwind CSS v4 requires the lightningcss-darwin-x64 package on macOS. Installing the specific platform binary resolved build failures without requiring full node_modules reinstallation.
Deployment Architecture
The application is designed to deploy as a static export to S3, served through CloudFront. The monorepo structure allows future API routes via a separate Lambda function (placeholder visible in the file modifications as /tmp/rdp-cf-function.js), enabling serverless microservices for contact forms, dynamic content, or CMS integration without bloating the static site.
What's Next
The foundation is live and rendering at http://localhost:3000/san-diego/ with working navigation. The next phase includes:
- CloudFront distribution configuration with the rickdrakeproductions.com certificate
- S3 bucket setup for static asset hosting
- CI/CD pipeline in GitHub Actions to build and deploy on push
- Content population for all city pages (services, fleet vehicles, portfolios)
- Contact form integration with Lambda backend
The multi-city architecture is now proven; scaling to new cities requires only adding entries to cities.ts and content-specific pages within new [city] subdirectories.