Architecture
System design for the Nutrisslim headless replatform. Decisions behind it: adr/. Delivery
phases and gates: ../README.md §Phases. Checkout detail: checkout.md. Markets: markets.md.
Shape
28 WooCommerce installs, one per market, each on its own wp.{market} host. One Next.js SSR
deployment serves all 28 public hosts and holds the BFF in-process. Cloudflare is the only page
cache. WordPress renders no public HTML; nf-commerce exposes nf/v1 and extends the Store API;
the theme renders only order-pay and the order-received redirect hop. attribution-monorepo
(Content Manager, later personalisation) writes into WordPress and is never in the render path.
flowchart TB
B((Browser)) --> CF["Cloudflare ×1<br/>cache (host,path,_rsc) · WAF · stale-if-error"]
CF -->|"miss / bypass"| FE
subgraph L3["Frontend ×1 deployment, 28 hosts"]
FE["Next.js SSR<br/>(store) cached · (commerce)(account) no-store"]
BFF["lib/api + lib/commerce (data layer, from Server Components)<br/>api/{cart,checkout,orders,fragments,preview,health} route handlers (HTTP semantics only)"]
FE --- BFF
end
subgraph L1["Store per market ×28 — wp.{market} host"]
NF["nf-commerce<br/>nf/v1 · Store API extensions · PayPal bridge · UPN · return-to · outbox → CF purge"]
WOO["WooCommerce core<br/>cart · totals · tax · shipping · orders · gateways"]
TH["nf-headless theme<br/>order-received hop · order-pay"]
NF --- WOO --- TH
end
BFF -->|"nf/v1 (X-NF-Key, XFF)"| NF
BFF -->|"/wc/store/v1 (Cart-Token, XFF)"| WOO
PSP["Stripe · PayPal · Mollie · Bankart"] -->|"/wc-api/* · /wp-json/paypal/*"| WOO
B -.->|"Stripe.js · PayPal SDK · PSP redirect"| PSP
PSP -.->|"return → order-received → 302 _nf_return_to"| TH
subgraph L2["attribution-monorepo (never in render path)"]
CM["content-manager"]
PZ["personalisation"]
end
CM -->|"nf/v1/content/write"| NF
NF -->|"outbox events"| CM
NF -->|"single-file purge"| CF
Stack
| Layer | Choice | Notes |
|---|---|---|
| Edge | Cloudflare: Cache Rules (key host+path+_rsc; bypass on order=/key= query, /cart, /checkout, /order/*, /account/*, /api/*), Cache Response Rules stale-if-error, WAF, Access on /wp/wp-admin, Turnstile | Plan tier per zone is a named dependency. Always Online off. Marketing params (utm_*, gclid, fbclid) stripped by URL rule — never “ignore query string”. |
| Frontend | Next.js 16.3 App Router, React 19.2, TS strict, output: 'standalone', proxy.ts (Node) for host → market and Cache-Control | SSR only, cacheComponents: false. Docker → GHCR → microk8s ×3, 512Mi limit, RSS watchdog in instrumentation.ts, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, deploymentId, buildId = git SHA, next/image optimisation off (media on R2 / Cloudflare Images per G8). |
| UI | Tailwind v4, shadcn/ui, tokens as CSS variables, next-intl catalogs (strings only; market = host), <Link prefetch={false}> default | Sections registry = React components. Browser floor Safari 16.4+ checked against pilot GA4 before final. |
| Data access | openapi-typescript types (committed, drift-checked) + openapi-fetch client for nf/v1; typed Store API client in lib/commerce; zod on every BFF request body | No raw fetch outside lib/api. Responses are not runtime-validated in production; fixtures + contract-check cover them. |
| BFF | lib/api (typed nf/v1 client) and lib/commerce (Store API client) are the server-only data layer, called directly from Server Components; app/api/{cart,checkout,orders,fragments,preview,health} route handlers exist only where HTTP semantics are required | Holds X-NF-Key per store; forwards X-Forwarded-For, User-Agent, Accept-Language; keep-alive per origin, per-origin bulkhead + circuit breaker; timeouts cart 2 s / checkout 15 s; Cart-Token in httpOnly nf_cart cookie (see checkout.md); per-token mutex on checkout. |
| Payments (browser) | Stripe.js Payment Element (+ handleNextAction → verification_endpoint) · PayPal JS SDK buttons via nf-commerce create-order bridge · nothing for COD/BACS/UPN · top-level redirect for Mollie and Bankart | Provider registry keyed by gateway id from Store API payment_methods. |
| Store | WordPress (Bedrock, pinned in store-template/composer.json), WooCommerce 11.x, PHP 8.3, HPOS, MariaDB 11, Object Cache Pro, system cron (DISABLE_WP_CRON, wp action-scheduler run every minute) | Allowlist 17–20 plugins; rate_limit_checkout on with proxy_support; trusted proxy = frontend egress CIDRs / Access service token. |
| Plugin | nf-commerce: Platform · Catalog · Content · Commerce (Store API extensions, PayPal bridge, UPN, return-to, webhooks, PayLink, purge) · Account (login → token merge) · Marketing · Fulfilment · Migration | Three-folder rule, PHPStan max, no WP calls in Service/. |
| Theme | nf-headless: redirect-front as a route map (Woo URL → frontend route; exempt wp-admin, wp-login, wp-json, wp-cron, wc-api, order-pay, order-received), preview-handoff, order-received hop → _nf_return_to, order-pay templates, admin tweaks | ≈12 files. |
| Services (monorepo) | content-manager (Elysia, Postgres, BullMQ, Clerk); later personalisation | Writes via nf/v1/content/write (base-version 409); drafts pushed to WP as status=draft revisions for preview. |
| Contracts | contracts/openapi.yaml, fixtures incl. per-gateway payment_data and payment_details | contract-check CI regenerates and fails on drift; contract test on Stripe payment_details keys. |
| CI/CD | plugin-ci, frontend-ci (tsc, eslint, vitest, Playwright vs DDEV), contract-check, pr-title, plugin-release, soak (k6); deploy (canary → waves) written once the cluster and O1 exist | Commitizen; main protected. |
Not chosen: Astro (kept as the written flip in ADR-003), WPGraphQL/WooGraphQL (ADR-013), CoCart, Faust.js, iframe of the Woo checkout.
Hosts
| Host | Serves |
|---|---|
nutrisslim.si … ×28 (public) | Frontend only. The browser never reaches WP here. Cookies: nf_cart (httpOnly), nf_consent, _td_*. |
wp.si.nutrisslim.com … ×28 (WP_HOME) | /wp/wp-admin behind Cloudflare Access · /wp-json/nf/v1 and /wp-json/wc/store/v1 from the frontend cluster only · /wc-api/*, /wp-json/paypal/*, /wp-json/mollie/* open for PSP callbacks · /order-pay, /order-received theme-rendered · everything else route-mapped 301 to the storefront. Woo absolute URLs in e-mails filtered to storefront routes. |
legacy.nutrisslim.si (Phase 5–6) | Old WP, read-only for 30 days after the DNS flip. |
PSP callbacks arrive on /wc-api/* (Stripe, Bankart, Mollie) and /wp-json/paypal/* — never on
/wc/v3. These paths are exempt from the theme route map and from Access.
Request paths
- Browse. Cloudflare hit, or miss →
proxy.tsresolves market fromHostand setsCache-Control: public, s-maxage=3600, stale-while-revalidate=60, stale-if-error=86400→ RSC page →nf/v1. Prices are in the HTML; purge onnf/price/changed;api/fragments/price(60 s) is the hydration backstop. 404 TTL 60 s via a Cloudflare rule on status. - Landing with integrated checkout.
checkout.md. /cart,/checkout,/order/[id],/account. Same components as the island, full page,no-store.- Edit. wp-admin ACF (Phase 1) or CM (Phase 2+) → Write API → outbox → purge. Preview: CM
pushes a draft revision → signed token →
api/previewreads thenf/v1draft (no-store). - Ops.
nf/v1/healthper store including outbox lag; frontend features gated onnf/v1/capabilities.
Cache and header mechanics (the price of Next)
- Next sets
Cache-Control: private, no-storeon dynamic pages unless a header is already present, soCache-Controlis owned bynext.config.ts headers()(path patterns per route group) and never set in components. Root layout isdynamic = 'force-dynamic'. - Client navigation requests the same path with
?_rsc=<hash>and relies onVary, which Cloudflare ignores for non-image responses; the cache key therefore includes_rsc, and the Phase 0 gate checks that?_rsc=is cached separately astext/x-component. <Link prefetch={false}>is the default to keep uncacheable prefetch variants off the origin.- Memory: RSS watchdog in
instrumentation.ts, 512Mi pod limit, 1 h k6 soak with flat RSS as the Phase 0 gate. - ESLint bans Next’s own caching APIs and request-state reads under
(store)(list in../CLAUDE.md).api/revalidatedoes not exist. - Flip condition (ADR-003): if the Phase 0 cache/soak gates or the Phase 1 CWV/origin-load gates fail
on these items, move to Astro 7 +
@astrojs/react+ node adapter. Plugin, contracts, Store API client and React sections carry over.
Purge
The plugin purges Cloudflare by single-file URL from its outbox: batched ≤100 URLs per call, per-store cap, backoff on 429 (limits are per account). Action Scheduler drives it, run by system cron. One Cloudflare credential per store. Health exposes outbox lag.
Consent and tracking
Data-plane, not cookie names. The frontend banner owns consent; the checkout island sends
consent, td_sid, td_ses in Store API extensions.nf; nf-commerce writes them to order
meta; the tracker’s server envelope reads order meta. WP never sees shopper cookies because the
BFF calls it server-to-server.
Invariants
- WP renders no public HTML except
order-payand theorder-receivedhop on the admin host. - Money is computed only in WooCommerce; the frontend formats.
- Cache key
(host, path, _rsc); cached HTML is always the complete generic page;(store)never reads request state. - No country conditionals in shared code.
- Plugin list is an allowlist.
- No cutover with a dirty
verify:*. - Monorepo never blocks render.
- Browser never holds an API key or Cart-Token in JS; webhooks signature-checked.
- Every visible-data change emits a domain event.
- Shopper IP/UA reach WooCommerce on every order.