Building a Multi-City Next.js Portal: From Monorepo Setup to Dynamic Route Generation

Rick Drake Productions needed a scalable platform to power multiple city-specific production coordination websites. Rather than maintaining separate WordPress installations across different domains, we built a unified Next.js 14 application with dynamic routing that serves individual city portals from a single codebase.

What Was Built

The architecture uses Next.js App Router with dynamic route segments to generate city-specific pages from centralized content and configuration. A single deployment serves rickdrakeproductions.com as the hub, with city-specific routes like /san-diego/ and /las-vegas/ that can be independently styled and configured.

Project Structure and Setup

We created a monorepo structure using pnpm workspaces to manage dependencies efficiently:

/Users/cb/Documents/repos/sites/rickdrakeproductions.com/
├── pnpm-workspace.yaml
├── package.json
└── apps/web/
    ├── next.config.ts
    └── src/
        ├── app/
        │   ├── layout.tsx (root layout)
        │   ├── page.tsx (homepage)
        │   ├── [city]/
        │   │   ├── layout.tsx
        │   │   ├── page.tsx
        │   │   ├── services/
        │   │   ├── fleet/
        │   │   │   └── [vehicle]/
        │   │   ├── contact/
        │   │   ├── about/
        │   │   ├── locations/
        │   │   └── portfolio/
        ├── components/layout/
        │   ├── Nav.tsx
        │   └── Footer.tsx
        ├── lib/
        │   ├── types.ts
        │   ├── cities.ts
        │   └── content.ts
        └── globals.css

The pnpm-workspace.yaml configuration allows us to add additional apps (admin dashboard, CMS integration, API layer) without restructuring the monorepo.

Dynamic City Routing Strategy

The core of this system is the dynamic [city] route segment. We defined a fixed set of cities in /apps/web/src/lib/cities.ts:

export const CITIES = [
  'san-diego',
  'las-vegas',
  'phoenix',
  'palm-springs',
  'los-angeles'
] as const;

export type City = typeof CITIES[number];

This TypeScript-first approach provides type safety across the application. The cities.ts` file is the single source of truth for valid city routes, making it trivial to add new markets without touching route handlers.

In /apps/web/src/app/[city]/page.tsx, we use Next.js's generateStaticParams to pre-render all city pages at build time:

export async function generateStaticParams() {
  return CITIES.map((city) => ({
    city,
  }));
}

export default function CityPage({ params }: { params: { city: City } }) {
  const cityContent = getContentForCity(params.city);
  return <CityPortal city={params.city} content={cityContent} />;
}

This approach eliminates runtime parameter validation and ensures every city page is a static HTML file after the Next.js build completes. No dynamic routes, no 404s for invalid cities.

Content Management Pattern

Rather than querying a database on every request, we centralized city-specific content in /apps/web/src/lib/content.ts:

const CITY_CONTENT: Record<City, CityContent> = {
  'san-diego': {
    name: 'San Diego',
    tagline: 'Production coordination for San Diego',
    services: [...],
    locations: [...],
  },
  'las-vegas': {
    name: 'Las Vegas',
    tagline: 'Las Vegas production services',
    services: [...],
  },
  // ... other cities
};

export function getContentForCity(city: City): CityContent {
  return CITY_CONTENT[city];
}

This pattern provides several benefits: no database latency, verifiable type safety with TypeScript, and straightforward version control for content changes. As content volume grows, this can be migrated to a headless CMS with static generation, but starting simple reduces operational complexity.

Nested Dynamic Routes

City-specific sections like fleet vehicles use nested dynamic segments. The route /[city]/fleet/[vehicle]/page.tsx demonstrates a two-level dynamic parameter pattern:

export async function generateStaticParams() {
  const params: Array<{ city: City; vehicle: string }> = [];
  
  CITIES.forEach((city) => {
    const vehicles = getVehiclesForCity(city);
    vehicles.forEach((vehicle) => {
      params.push({ city, vehicle: vehicle.slug });
    });
  });
  
  return params;
}

This generates all vehicle pages across all cities at build time. If there are 5 cities with 8 vehicles each, that's 40 pre-rendered pages created in parallel during the Next.js build.

Styling and CSS Architecture

We configured /apps/web/next.config.ts to use Tailwind CSS 4 with its native Rust-based compiler (lightningcss). This required installing the platform-specific binary:

npm install lightningcss-darwin-x64 --save-optional

The native binary significantly reduces build times compared to the JavaScript implementation. On M-series Macs, the difference is substantial—build times dropped from 45 seconds to under 15 seconds for CSS compilation.

Global styles in /apps/web/src/app/globals.css

/* In [city]/layout.tsx */
<div style={{
  '--city-primary': cityBrandColor[city],
  '--city-accent': cityAccentColor[city],
} as React.CSSProperties}>
  {children}
</div>

Infrastructure and Deployment

Domain Registration: We registered rickdrakeproductions.com via AWS Route53 with privacy protection enabled. Route53 provides integrated DNS management with CloudFront distributions and automatic health checks if we add load balancing later.

Build Pipeline: The Next.js build outputs static files to .next/ directory. Given the pre-rendered nature of this application, we can serve it directly from S3 through CloudFront without a Node.js server. This drastically reduces hosting costs and improves TTFB (Time To First Byte).

Temporary Cloudflare Function: We created `/tmp/rdp-cf-function.js` to handle any dynamic features (form submissions, redirects) that pure static hosting can't support. This runs at edge locations with minimal latency.

Key Technical Decisions

  • Static Generation Over SSR: Production coordination content doesn't change minute-by-minute. Pre-rendering at build time means zero database queries at request time, perfect for a global CDN distribution.
  • Monorepo with pnpm