04 — Frontend Architecture
PRD Document · Savoy Signature Hotels — Multi-Site Headless Platform
Version: 1.0 · Date: 2026-03-04
Related docs:01_General_Architecture.md,03_MultiSite_and_Domains.md,05_Design_System_and_Theming.md,07_Modules_and_Templates.md
1. Purpose
Section titled “1. Purpose”This document defines the frontend architecture for the Savoy Signature multi-site platform built with Next.js 16 (App Router + Turbopack). It covers the monorepo structure, folder conventions, routing, data fetching, component architecture, error handling, and code standards.
2. Monorepo Structure
Section titled “2. Monorepo Structure”The project uses a Turborepo monorepo with pnpm workspaces:
savoy-platform/├── apps/│ ├── web/ # Next.js 16 application (all 8 sites)│ └── storybook/ # Storybook instance├── packages/│ ├── ui/ # Shared UI components (buttons, inputs, etc.)│ ├── modules/ # CMS-driven modules (M01–Mxx)│ ├── themes/ # Per-site theme CSS files + design tokens│ ├── cms-client/ # Umbraco API client + types│ ├── config/ # Shared ESLint, TypeScript, and other configs│ └── utils/ # Shared utilities and helpers├── turbo.json # Turborepo pipeline configuration├── pnpm-workspace.yaml├── package.json└── tsconfig.base.json2.1 Package Dependency Graph
Section titled “2.1 Package Dependency Graph”3. Next.js Application Structure
Section titled “3. Next.js Application Structure”apps/web/├── src/│ ├── app/ # Next.js App Router│ │ ├── layout.tsx # Root layout (site context, theme, fonts)│ │ ├── not-found.tsx # Global 404 (themed per site)│ │ ├── error.tsx # Global error boundary (themed per site)│ │ ├── robots.ts # Dynamic robots.txt per site│ │ ├── sitemap.ts # Dynamic sitemap.xml per site│ │ └── [locale]/ # Language segment (pt, en)│ │ ├── layout.tsx # Locale layout (lang, dir, hreflang)│ │ ├── page.tsx # Homepage│ │ └── [...slug]/ # CMS-driven catch-all routes│ │ └── page.tsx # Dynamic page resolver│ ├── proxy.ts # Site resolution (replaces middleware.ts)│ ├── helpers/│ │ ├── site-resolver.ts # Site config resolution from request│ │ ├── content-resolver.ts # Resolves Umbraco content by slug│ │ ├── module-mapper.ts # Maps Umbraco Element Types → React modules│ │ └── url-builder.ts # Builds URLs respecting site + locale│ ├── hooks/│ │ ├── useSite.ts # Client-side site context hook│ │ └── useLocale.ts # Current locale hook│ └── lib/│ ├── fonts.ts # Font loading (next/font)│ └── metadata.ts # Dynamic metadata generation├── public/│ ├── favicons/ # Per-site favicons│ │ ├── savoy-signature/│ │ ├── savoy-palace/│ │ └── ...│ └── robots.txt # Fallback robots.txt├── next.config.ts├── tsconfig.json└── package.json4. Routing
Section titled “4. Routing”4.1 Route Resolution Flow
Section titled “4.1 Route Resolution Flow”4.2 Dynamic Page Resolution
Section titled “4.2 Dynamic Page Resolution”import { headers } from 'next/headers';import { notFound } from 'next/navigation';import { fetchContentByPath } from '@savoy/cms-client';import { ModuleRenderer } from '@savoy/modules';import { generatePageMetadata } from '@/lib/metadata';
interface PageProps { params: Promise<{ locale: string; slug: string[]; }>;}
export async function generateMetadata({ params }: PageProps) { const { locale, slug } = await params; const headersList = await headers(); const siteKey = headersList.get('x-site-key')!;
const content = await fetchContentByPath(siteKey, locale, slug.join('/')); if (!content) return {};
return generatePageMetadata(content, siteKey, locale);}
export default async function DynamicPage({ params }: PageProps) { const { locale, slug } = await params; const headersList = await headers(); const siteKey = headersList.get('x-site-key')!;
const content = await fetchContentByPath(siteKey, locale, slug.join('/'));
if (!content) { notFound(); }
return ( <main> <ModuleRenderer modules={content.modules} siteKey={siteKey} locale={locale} /> </main> );}4.3 Locale Routing
Section titled “4.3 Locale Routing”| URL Pattern | Locale | Site |
|---|---|---|
/pt/... | Portuguese | Resolved from hostname/path |
/en/... | English | Resolved from hostname/path |
/ (root) | Redirect to default locale | 302 → /{defaultLocale} |
// proxy.ts — locale handling
export function proxy(request: NextRequest) { const site = resolveSiteFromRequest(request); const pathname = request.nextUrl.pathname;
// Strip site path prefix for locale detection const localePath = pathname.replace(site.pathPrefix, '');
// Redirect root to default locale if (localePath === '/' || localePath === '') { return NextResponse.redirect( new URL(`${site.pathPrefix}/${site.defaultLocale}`, request.url) ); }
const response = NextResponse.next(); response.headers.set('x-site-key', site.key); response.headers.set('x-site-theme', site.theme); response.headers.set('x-site-root-id', site.umbracoRootId); return response;}5. Data Fetching
Section titled “5. Data Fetching”5.1 Strategy
Section titled “5.1 Strategy”| Data Type | Fetching Method | Cache |
|---|---|---|
| Page content | React Server Components (RSC) | Edge cache (Cloudflare) — no Next.js cache |
| Navigation | RSC in layout | Edge cache (shares page cache lifetime) |
| Shared content (footer, labels) | RSC in layout | Edge cache |
| Site config | proxy.ts + server headers | Per-request (no cache needed) |
| Booking widget data | Client-side (Navarino JS SDK) | Browser cache |
| Analytics | Client-side (GTM) | N/A |
5.2 CMS Client
Section titled “5.2 CMS Client”const UMBRACO_API = process.env.UMBRACO_API_URL;
export async function fetchContentByPath( siteKey: string, locale: string, path: string): Promise<PageContent | null> { const response = await fetch( `${UMBRACO_API}/umbraco/delivery/api/v2/content/item/${path}`, { headers: { 'Accept-Language': locale, 'Start-Item': siteKey, // Umbraco root node alias }, // No Next.js cache — Cloudflare handles caching cache: 'no-store', } );
if (!response.ok) return null; return response.json();}
export async function fetchNavigation( siteKey: string, locale: string): Promise<NavigationItem[]> { const response = await fetch( `${UMBRACO_API}/umbraco/delivery/api/v2/content?filter=contentType:navigationItem&sort=sortOrder:asc`, { headers: { 'Accept-Language': locale, 'Start-Item': siteKey, }, cache: 'no-store', } );
if (!response.ok) return []; const data = await response.json(); return data.items;}5.3 use cache Usage
Section titled “5.3 use cache Usage”Following ADR-008, use cache is used sparingly for data that is expensive to compute and doesn’t change per-request:
// Example: cached site configuration lookup'use cache';
export async function getSiteNavigationStructure(siteKey: string) { // This data changes rarely and is expensive to build // Cache at application level for 5 minutes const nav = await fetchNavigation(siteKey, 'pt'); return buildNavigationTree(nav);}Rule: Page content is NEVER cached at the Next.js level. Cloudflare is the single caching layer for HTML output.
6. Component Architecture
Section titled “6. Component Architecture”6.1 Component Hierarchy
Section titled “6.1 Component Hierarchy”6.2 Component Categories
Section titled “6.2 Component Categories”| Category | Location | Description | Server/Client |
|---|---|---|---|
| UI Components | packages/ui/ | Low-level, reusable, theme-aware (Button, Input, Card, Image, Icon) | Both |
| Modules | packages/modules/ | CMS-driven content blocks mapped to Umbraco Element Types (Hero, RoomCard, Gallery) | Server (default) |
| Templates | apps/web/src/app/ | Page-level layouts that compose modules based on CMS configuration | Server |
| Widgets | packages/ui/widgets/ | Interactive client-side components (BookingBar, LanguageSwitcher, CookieConsent) | Client |
6.3 Component File Convention
Section titled “6.3 Component File Convention”packages/modules/src/m05-hero-slider/├── index.ts # Public export├── HeroSlider.tsx # Main component├── HeroSlider.module.css # Scoped styles (CSS Modules)├── HeroSlider.types.ts # TypeScript types / props interface├── HeroSlider.stories.tsx # Storybook stories├── HeroSlider.test.tsx # Unit tests (Vitest)└── HeroSlider.mapper.ts # Maps Umbraco API response → component props6.4 Module Mapper Pattern
Section titled “6.4 Module Mapper Pattern”Every module has a mapper that transforms the raw Umbraco API response into typed React props:
import { UmbracoElement } from '@savoy/cms-client';import { HeroSliderProps } from './HeroSlider.types';
export function mapHeroSlider(element: UmbracoElement): HeroSliderProps { return { title: element.properties.title, subtitle: element.properties.subtitle, slides: element.properties.slides.map((slide: any) => ({ image: { src: slide.image.url, alt: slide.image.name, width: slide.image.width, height: slide.image.height, }, caption: slide.caption, cta: slide.cta ? { label: slide.cta.name, href: slide.cta.url, } : undefined, })), autoplay: element.properties.autoplay ?? true, interval: element.properties.interval ?? 5000, };}6.5 Module Renderer
Section titled “6.5 Module Renderer”The Module Renderer dynamically resolves and renders CMS modules:
import { lazy, Suspense } from 'react';import { UmbracoElement } from '@savoy/cms-client';import { moduleRegistry } from './registry';
interface ModuleRendererProps { modules: UmbracoElement[]; siteKey: string; locale: string;}
export function ModuleRenderer({ modules, siteKey, locale }: ModuleRendererProps) { return ( <> {modules.map((module, index) => { const registration = moduleRegistry[module.contentType];
if (!registration) { console.warn(`Unknown module type: ${module.contentType}`); return null; }
const { component: Component, mapper, moduleId } = registration; const props = mapper(module);
return ( <Component key={`${module.contentType}-${index}`} {...props} siteKey={siteKey} locale={locale} moduleId={moduleId} /> ); })} </> );}7. Error Handling
Section titled “7. Error Handling”7.1 Error Pages
Section titled “7.1 Error Pages”| Error | File | Behavior |
|---|---|---|
| 404 | app/not-found.tsx | Themed per site; suggests navigation; logs to analytics |
| 500 | app/error.tsx | Themed per site; shows friendly message; reports to App Insights |
| API Error | In component tree | Graceful degradation — hide failed module, show rest of page |
7.2 Error Boundary Strategy
Section titled “7.2 Error Boundary Strategy”'use client';
import { useEffect } from 'react';
export default function ErrorPage({ error, reset,}: { error: Error & { digest?: string }; reset: () => void;}) { useEffect(() => { // Report to Application Insights console.error('Page error:', error); }, [error]);
return ( <div className="error-page"> <h1>Something went wrong</h1> <p>We're working on fixing this. Please try again.</p> <button onClick={reset}>Try again</button> </div> );}8. Redirects Management
Section titled “8. Redirects Management”8.1 Sources of Redirects
Section titled “8.1 Sources of Redirects”| Source | Type | Management |
|---|---|---|
| CMS-managed | 301/302 | Editors create redirects in Umbraco (URL Redirect Management) |
| Static | 301 | Defined in next.config.ts for known permanent redirects |
| Locale | 302 | Automatic redirect from / to /{defaultLocale} |
| Legacy URLs | 301 | Migration redirects from old site URLs |
8.2 Implementation
Section titled “8.2 Implementation”const nextConfig: NextConfig = { async redirects() { return [ // Legacy URL redirects (from old site) // These are static and defined at build time ]; },};Dynamic redirects (CMS-managed) are resolved at request time in proxy.ts by checking a redirect map fetched from Umbraco.
9. Performance Considerations
Section titled “9. Performance Considerations”| Concern | Approach |
|---|---|
| Bundle size | Turbopack tree-shaking; dynamic imports for heavy modules; analyze with @next/bundle-analyzer |
| Images | next/image for automatic optimization (WebP/AVIF, responsive srcset); Umbraco media served via CDN |
| Fonts | next/font for zero-layout-shift font loading; subset per site theme |
| Third-party scripts | GTM loaded with next/script strategy afterInteractive; Navarino widget lazy-loaded |
| CSS | CSS Modules for scoped styles; no runtime CSS-in-JS; theme via CSS variables (zero JS overhead) |
| Core Web Vitals targets | LCP < 2.5s, FID < 100ms, CLS < 0.1, INP < 200ms |
10. Code Standards
Section titled “10. Code Standards”Full coding standards in
A07_Coding_Standards.md
10.1 Key Conventions
Section titled “10.1 Key Conventions”| Convention | Rule |
|---|---|
| Language | TypeScript strict mode (strict: true) everywhere |
| Components | Functional components only; named exports |
| Naming | PascalCase for components, camelCase for functions/variables, kebab-case for files |
| Imports | Absolute imports via @/ for app, @savoy/ for packages |
| Styling | CSS Modules (.module.css); no inline styles except dynamic values |
| Testing | Every module has .test.tsx and .stories.tsx |
| Linting | ESLint + Prettier (Biome considered); run outside Next.js (Next.js 16 removed next lint) |
| Commits | Conventional Commits (feat:, fix:, chore:) |
| PRs | Max 400 lines changed; link to Zoho Project task; reviewed by AI QA (openClaw) + human |
10.2 TypeScript Configuration
Section titled “10.2 TypeScript Configuration”// tsconfig.base.json (root){ "compilerOptions": { "strict": true, "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "module": "esnext", "moduleResolution": "bundler", "jsx": "react-jsx", "incremental": true, "declaration": true, "declarationMap": true, "sourceMap": true, "esModuleInterop": true, "forceConsistentCasingInImports": true, "isolatedModules": true, "noUncheckedIndexedAccess": true, "paths": { "@savoy/*": ["./packages/*/src"] } }}11. Acceptance Criteria
Section titled “11. Acceptance Criteria”- Monorepo builds successfully with
pnpm buildfrom root -
apps/webserves all 8 sites with correct routing and theming - Dynamic page resolution fetches content from Umbraco and renders modules
- Module Renderer correctly maps all registered modules
- 404 and error pages are themed per site
- Locale redirect works (
/→/pt) - No runtime CSS-in-JS — all styles via CSS Modules + variables
- TypeScript strict mode passes with zero errors
- All modules have stories in Storybook
- Bundle size per-page < 200KB (gzipped, excluding images)
- Core Web Vitals targets met (LCP < 2.5s, CLS < 0.1)
Next document: 05_Design_System_and_Theming.md