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—PageLinkvalidates the href (/,#, orhttp/https/mailto/tel) and renders an unusable link as disabled text. Link variants map ontoshared/ui/Buttonvariants.components/media-image.tsx— a native<img>, notnext/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 inPublicContentService; 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,getPathnamefromi18n/navigation.ts— nevernext/linkornext/navigationdirectly, 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) underi18n/translations/, merged at request time; a missing locale file falls back per namespace. i18n/types.tsderives theMessagestype from the English files — a missing key is a type error.
SEO
app/[locale]/layout.tsxsetsmetadataBasefromsiteUrl()plus the default title template.web-runtime/lib/page-metadata.tsbuilds per-page metadata — title, description, canonical, hreflang withx-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.tswalks the paginated backend listing andbuildSitemapgroups translations intoalternates.languages.app/robots.tsallows everything except/*/preview.web-runtime/lib/structured-data.tsxemitsBreadcrumbListand, on editorial pages,Article.