# Next.js Web Architecture Architecture of the public website (`apps/web/`): a Next.js App Router app that server-renders the pages editors publish in [Web Manager](../../core/web-manager/Readme.md). App-specific setup (env vars, dev commands, tests) lives in [apps/web/Readme.md](../../../apps/web/Readme.md). ## How a Request Works ```txt GET /en/about-us proxy.ts (next-intl middleware) validates the locale segment app/[locale]/[[...path]]/page.tsx resolvePageRenderData('en', 'about-us') web-runtime/api/render-data.ts GET {BACKEND_API_URL}/web-manager/public/pages/resolve?locale=en&path=about-us normalizePageResolution(payload) web-runtime/data/normalize.ts PageRenderer web-runtime/renderer.tsx SiteHeader → RegisteredPageLayout → SiteFooter ├─ PageHero ├─ Breadcrumbs └─ PageBlocks → block registry → ContentBlock, FaqBlock, … ``` ## Layout ```txt apps/web/ ├── app/ Next.js routes — thin, they only resolve data and delegate │ ├── [locale]/ │ │ ├── layout.tsx , NextIntlClientProvider, metadataBase, globals.css │ │ ├── [[...path]]/page.tsx catch-all: every CMS page in this locale │ │ ├── preview/page.tsx token-gated draft rendering (force-dynamic, noindex) │ │ └── not-found.tsx 404 screen │ ├── api/revalidate/route.ts secret-guarded cache purge, called by the backend │ ├── sitemap.ts, robots.ts generated from the public API │ └── globals.css one line: @import 'tailwindcss' ├── web-runtime/ the rendering engine — everything that turns CMS data into a page │ ├── api/ HTTP client, render-data resolution, cache tags │ ├── data/normalize.ts untrusted payload → typed contract │ ├── types.ts that contract │ ├── blocks/ 6 block renderers + registry + unsupported fallback │ ├── layouts/ 3 page layouts + registry, hero, breadcrumbs │ ├── chrome/ site header, nav, footer, language switch │ ├── components/ link, image and rich-text primitives │ ├── lib/ metadata, site URL, sitemap, structured data │ └── renderer.tsx header + layout + footer ├── i18n/ next-intl wiring, navigation helpers, translation JSON ├── shared/ui/ design-system primitives (Button) ├── site.config.ts locales and default locale ├── proxy.ts the next-intl middleware └── tests/ Vitest specs for the pure logic ``` ### Layer responsibilities | Layer | Owns | Never does | | ------------------- | --------------------------------------------------------------- | ----------------------------- | | `app/` | Route params, data resolution, `notFound()`, `generateMetadata` | Markup beyond composition | | `web-runtime/api/` | Every backend call, cache tags, `server-only` | Rendering | | `web-runtime/data/` | Turning untrusted JSON into `types.ts` shapes | Fetching or throwing | | `web-runtime/` | All page markup, registries, primitives | Direct `fetch` calls | | `i18n/` | Locale resolution, `Link`/`getPathname`, message loading | Content | | `shared/ui/` | Reusable, content-agnostic primitives | Knowing about pages or blocks | ## Backend Boundary `web-runtime/api/web-runtime-api.ts` is the single door to the backend — a `server-only` module, a lazily constructed singleton, and the only place `fetch` appears. | Call | Endpoint | Cache | | ------------------ | ----------------------------------- | ------------------------- | | `resolvePage` | `/web-manager/public/pages/resolve` | `revalidate: 60` + tags | | `listBlogPosts` | `/web-manager/public/blog/pages` | `revalidate: 60` + tags | | `listSitemapPages` | `/web-manager/public/pages/sitemap` | `revalidate: 3600` + tags | | `getSettings` | `/web-manager/public/settings` | `revalidate: 60` + tags | | `getPreview` | `/web-manager/public/pages/preview` | `cache: 'no-store'` | A `404` becomes `null` (the caller renders the 404 page); any other non-OK status throws. The sitemap walk is capped at 50 requests. Every response goes through `data/normalize.ts` first. Its one rule is **degrade, never throw**: a missing field becomes a default, an unknown block type becomes `UnknownBlock`, a malformed media object disappears. `types.ts` is what the rest of the app may assume after that. Publishing purges this app's cache: the backend POSTs tag names to `app/api/revalidate/route.ts`, guarded by `REVALIDATE_SECRET`. The tags in `web-runtime/api/cache-tags.ts` mirror the backend's — see [Web Manager caching](../../core/web-manager/integration.md#caching-and-revalidation). ## Rendering Two registries decide what gets drawn, and both fall back rather than fail: ```txt layouts/registry.tsx landing → StandardPageLayout unknown → StandardPageLayout blog → BlogPageLayout blog-item → EditorialPageLayout blocks/registry.tsx content, faq, media-content, articles, banner, media unknown → UnsupportedBlock (dev-only banner, nothing in production) ``` The CMS can publish a page type or block the site has not learned yet — the worst case is a missing section, never a broken deploy. `StandardPageLayout` is hero + blocks. `EditorialPageLayout` adds breadcrumbs, `Article` JSON-LD, and a narrower reading measure. `BlogPageLayout` ignores the page's blocks and fetches its own post list (12 per page, `?page=` pager). Each sets `data-page-layout` on its `
` — the hook for tests and debugging. `Breadcrumbs` renders nothing for a single-item trail and emits `BreadcrumbList` JSON-LD. Three shared primitives: - **`components/links.tsx`** — `PageLink` validates the href (`/`, `#`, or `http/https/mailto/tel`) and renders an unusable link as disabled text. Link variants map onto `shared/ui/Button` variants. - **`components/media-image.tsx`** — a native ``, not `next/image`: media origins are CMS-managed and unrestricted, so the optimizer's allowlist would be a permanent tax. - **`components/rich-text.tsx`** — `dangerouslySetInnerHTML`. Safe **only** because the backend sanitizes rich text in `PublicContentService`; never render HTML from any other source through it. ## Internationalization Locales live in `site.config.ts` (`en`, `uk`, `de`). `i18n/routing.ts` sets `localePrefix` to `always` with multiple locales and `never` with one, so a single-language site serves clean URLs. - Navigate with `Link`, `redirect`, `usePathname`, `useRouter`, `getPathname` from `i18n/navigation.ts` — never `next/link` or `next/navigation` directly, or the locale prefix is lost. - CMS hrefs arrive locale-prefixed; pass them through `toPublicHref` (`i18n/content-href.ts`). - Messages are five namespaces (`messages`, `pages`, `components`, `errors`, `form`) under `i18n/translations/`, merged at request time; a missing locale file falls back per namespace. - `i18n/types.ts` derives the `Messages` type from the English files — a missing key is a type error. ## SEO - `app/[locale]/layout.tsx` sets `metadataBase` from `siteUrl()` plus the default title template. - `web-runtime/lib/page-metadata.ts` builds per-page metadata — title, description, canonical, hreflang with `x-default`, and Open Graph / Twitter cards from the hero image. A pure function of the render data, which is why it has direct tests. - `app/sitemap.ts` walks the paginated backend listing and `buildSitemap` groups translations into `alternates.languages`. - `app/robots.ts` allows everything except `/*/preview`. - `web-runtime/lib/structured-data.tsx` emits `BreadcrumbList` and, on editorial pages, `Article`.