Files
admin/docs/architecture/web

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. App-specific setup (env vars, dev commands, tests) lives in apps/web/Readme.md.

How a Request Works

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

apps/web/
├── app/                    Next.js routes — thin, they only resolve data and delegate
│   ├── [locale]/
│   │   ├── layout.tsx          <html>, 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.

Rendering

Two registries decide what gets drawn, and both fall back rather than fail:

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 <main> — 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 <img>, 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.