Building a Multi-City Production Portal: Next.js 14 Architecture with Dynamic Route Generation

We recently scaffolded and deployed the foundational infrastructure for rickdrakeproductions.com, a multi-city web platform serving a production coordination business. This post covers the technical architecture decisions, implementation patterns, and infrastructure setup that enables city-specific content at scale.

Project Structure & Monorepo Setup

The project uses a pnpm monorepo structure to manage multiple applications and shared dependencies efficiently.

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
        │   ├── components/
        │   │   └── layout/
        │   │       ├── Nav.tsx
        │   │       └── Footer.tsx
        │   └── lib/
        │       ├── types.ts
        │       ├── cities.ts
        │       └── content.ts

We chose pnpm over npm because it provides better dependency resolution, faster installation times, and cleaner node_modules structure — crucial when managing multiple applications with shared utilities. The pnpm-workspace.yaml configuration enables monorepo semantics without duplicating dependencies across workspace packages.

Dynamic Route Generation with Catch-All Segments

The core architectural pattern uses Next.js 14's App Router with dynamic route segments. The [city] directory is a catch-all segment that enables us to serve content for any city without hardcoding routes.

File: apps/web/src/app/[city]/page.tsx

export async function generateStaticParams() {
  const cities = await getCities();
  return cities.map(city => ({
    city: city.slug
  }));
}

export default async function CityPage({ params }: { params: { city: string } }) {
  const cityData = await getCityContent(params.city);
  return (
    <div>
      <h1>{cityData.name}</h1>
      {/* city-specific content */}
    </div>
  );
}

The generateStaticParams() function enables static site generation (SSG) for all city pages at build time. This was a key decision: rather than server-rendering each city page on request, we pre-render them during the build process. This provides:

  • Fast page loads: No runtime computation for city lookups or content fetching
  • Reduced infrastructure costs: Serves static HTML from CloudFront edge locations
  • SEO benefits: Search engines see fully-rendered HTML immediately
  • Incremental Static Regeneration (ISR): Optionally refresh content on a schedule without full rebuilds

Content Organization & Type Safety

To support multiple cities and content types, we created a centralized content layer:

File: apps/web/src/lib/types.ts

export interface City {
  id: string;
  name: string;
  slug: string;
  description: string;
}

export interface Service {
  id: string;
  name: string;
  description: string;
}

export interface Vehicle {
  id: string;
  name: string;
  type: string;
}

File: apps/web/src/lib/cities.ts

export const cities: City[] = [
  {
    id: 'san-diego',
    name: 'San Diego',
    slug: 'san-diego',
    description: 'Production coordination in San Diego'
  },
  {
    id: 'las-vegas',
    name: 'Las Vegas',
    slug: 'las-vegas',
    description: 'Production coordination in Las Vegas'
  }
];

export async function getCities(): Promise<City[]> {
  return cities;
}

export async function getCityBySlug(slug: string): Promise<City | null> {
  return cities.find(c => c.slug === slug) ?? null;
}

Using TypeScript interfaces ensures compile-time type safety across all city pages. The cities.ts` module serves as the single source of truth for available cities, making it trivial to add Phoenix, Palm Springs, or LA later by adding entries to the array.

Nested Routes for City-Specific Pages

Each city gets its own set of sub-routes — services, fleet, contact, about, locations, and portfolio. These are implemented as nested routes under [city]:

File: apps/web/src/app/[city]/services/page.tsx

export async function generateStaticParams() {
  const cities = await getCities();
  return cities.map(city => ({
    city: city.slug
  }));
}

export default async function ServicesPage({ params }: { params: { city: string } }) {
  const cityData = await getCityBySlug(params.city);
  return (
    <div>
      <h1>Services in {cityData?.name}</h1>
      {/* city-specific services */}
    </div>
  );
}

This pattern scales automatically. When we add a new city to cities.ts, the build process regenerates all nested pages for that city without any route code changes.

Double-nested routes for vehicles demonstrate this further:

File: apps/web/src/app/[city]/fleet/[vehicle]/page.tsx

export async function generateStaticParams() {
  const cities = await getCities();
  const params = [];
  
  for (const city of cities) {
    const vehicles = await getVehiclesByCity(city.slug);
    vehicles.forEach(vehicle => {
      params.push({
        city: city.slug,
        vehicle: vehicle.slug
      });
    });
  }
  
  return params;
}

This generates static pages for every city+vehicle combination, enabling URLs like /san-diego/fleet/sound-truck-1.

Infrastructure & Domain Setup

Domain registration was handled via AWS Route53 rather than external registrars, integrating cleanly with the rest of the AWS stack:

  • Domain: rickdrakeproductions.com registered in Route53 with privacy protection enabled