Skip to content

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


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.


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.json

apps/web

apps/storybook

packages/ui

packages/modules

packages/themes

packages/cms-client

packages/utils

packages/config


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.json

UmbracoPageLayoutproxy.tsRequestUmbracoPageLayoutproxy.tsRequestIncoming requestResolve site from hostname + pathSet x-site-key, x-site-theme headersLoad theme, fonts, global dataRender page componentFetch content by site + locale + slugContent JSON (modules, metadata)Map modules to React componentsRendered HTML
app/[locale]/[...slug]/page.tsx
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>
);
}
URL PatternLocaleSite
/pt/...PortugueseResolved from hostname/path
/en/...EnglishResolved from hostname/path
/ (root)Redirect to default locale302 → /{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;
}

Data TypeFetching MethodCache
Page contentReact Server Components (RSC)Edge cache (Cloudflare) — no Next.js cache
NavigationRSC in layoutEdge cache (shares page cache lifetime)
Shared content (footer, labels)RSC in layoutEdge cache
Site configproxy.ts + server headersPer-request (no cache needed)
Booking widget dataClient-side (Navarino JS SDK)Browser cache
AnalyticsClient-side (GTM)N/A
packages/cms-client/src/client.ts
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;
}

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.


Foundation (packages/themes)

UI Layer (packages/ui)

Module Layer (packages/modules)

App Layer (apps/web)

Layouts

Pages

M01 — Header

M05 — Hero Slider

M.. — Other Modules

Button

Input

Card

ResponsiveImage

Design Tokens

Theme CSS

CategoryLocationDescriptionServer/Client
UI Componentspackages/ui/Low-level, reusable, theme-aware (Button, Input, Card, Image, Icon)Both
Modulespackages/modules/CMS-driven content blocks mapped to Umbraco Element Types (Hero, RoomCard, Gallery)Server (default)
Templatesapps/web/src/app/Page-level layouts that compose modules based on CMS configurationServer
Widgetspackages/ui/widgets/Interactive client-side components (BookingBar, LanguageSwitcher, CookieConsent)Client
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 props

Every module has a mapper that transforms the raw Umbraco API response into typed React props:

packages/modules/src/m05-hero-slider/HeroSlider.mapper.ts
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,
};
}

The Module Renderer dynamically resolves and renders CMS modules:

packages/modules/src/ModuleRenderer.tsx
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} />
);
})}
</>
);
}

ErrorFileBehavior
404app/not-found.tsxThemed per site; suggests navigation; logs to analytics
500app/error.tsxThemed per site; shows friendly message; reports to App Insights
API ErrorIn component treeGraceful degradation — hide failed module, show rest of page
app/error.tsx
'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>
);
}

SourceTypeManagement
CMS-managed301/302Editors create redirects in Umbraco (URL Redirect Management)
Static301Defined in next.config.ts for known permanent redirects
Locale302Automatic redirect from / to /{defaultLocale}
Legacy URLs301Migration redirects from old site URLs
next.config.ts
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.


ConcernApproach
Bundle sizeTurbopack tree-shaking; dynamic imports for heavy modules; analyze with @next/bundle-analyzer
Imagesnext/image for automatic optimization (WebP/AVIF, responsive srcset); Umbraco media served via CDN
Fontsnext/font for zero-layout-shift font loading; subset per site theme
Third-party scriptsGTM loaded with next/script strategy afterInteractive; Navarino widget lazy-loaded
CSSCSS Modules for scoped styles; no runtime CSS-in-JS; theme via CSS variables (zero JS overhead)
Core Web Vitals targetsLCP < 2.5s, FID < 100ms, CLS < 0.1, INP < 200ms

Full coding standards in A07_Coding_Standards.md

ConventionRule
LanguageTypeScript strict mode (strict: true) everywhere
ComponentsFunctional components only; named exports
NamingPascalCase for components, camelCase for functions/variables, kebab-case for files
ImportsAbsolute imports via @/ for app, @savoy/ for packages
StylingCSS Modules (.module.css); no inline styles except dynamic values
TestingEvery module has .test.tsx and .stories.tsx
LintingESLint + Prettier (Biome considered); run outside Next.js (Next.js 16 removed next lint)
CommitsConventional Commits (feat:, fix:, chore:)
PRsMax 400 lines changed; link to Zoho Project task; reviewed by AI QA (openClaw) + human
// 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"]
}
}
}

  • Monorepo builds successfully with pnpm build from root
  • apps/web serves 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