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