Skip to content

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

LayerChoiceNotes
EdgeCloudflare: 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, TurnstilePlan tier per zone is a named dependency. Always Online off. Marketing params (utm_*, gclid, fbclid) stripped by URL rule — never “ignore query string”.
FrontendNext.js 16.3 App Router, React 19.2, TS strict, output: 'standalone', proxy.ts (Node) for host → market and Cache-ControlSSR 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).
UITailwind v4, shadcn/ui, tokens as CSS variables, next-intl catalogs (strings only; market = host), <Link prefetch={false}> defaultSections registry = React components. Browser floor Safari 16.4+ checked against pilot GA4 before final.
Data accessopenapi-typescript types (committed, drift-checked) + openapi-fetch client for nf/v1; typed Store API client in lib/commerce; zod on every BFF request bodyNo raw fetch outside lib/api. Responses are not runtime-validated in production; fixtures + contract-check cover them.
BFFlib/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 requiredHolds 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 (+ handleNextActionverification_endpoint) · PayPal JS SDK buttons via nf-commerce create-order bridge · nothing for COD/BACS/UPN · top-level redirect for Mollie and BankartProvider registry keyed by gateway id from Store API payment_methods.
StoreWordPress (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.
Pluginnf-commerce: Platform · Catalog · Content · Commerce (Store API extensions, PayPal bridge, UPN, return-to, webhooks, PayLink, purge) · Account (login → token merge) · Marketing · Fulfilment · MigrationThree-folder rule, PHPStan max, no WP calls in Service/.
Themenf-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 personalisationWrites via nf/v1/content/write (base-version 409); drafts pushed to WP as status=draft revisions for preview.
Contractscontracts/openapi.yaml, fixtures incl. per-gateway payment_data and payment_detailscontract-check CI regenerates and fails on drift; contract test on Stripe payment_details keys.
CI/CDplugin-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 existCommitizen; 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

HostServes
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

  1. Browse. Cloudflare hit, or miss → proxy.ts resolves market from Host and sets Cache-Control: public, s-maxage=3600, stale-while-revalidate=60, stale-if-error=86400 → RSC page → nf/v1. Prices are in the HTML; purge on nf/price/changed; api/fragments/price (60 s) is the hydration backstop. 404 TTL 60 s via a Cloudflare rule on status.
  2. Landing with integrated checkout. checkout.md.
  3. /cart, /checkout, /order/[id], /account. Same components as the island, full page, no-store.
  4. Edit. wp-admin ACF (Phase 1) or CM (Phase 2+) → Write API → outbox → purge. Preview: CM pushes a draft revision → signed token → api/preview reads the nf/v1 draft (no-store).
  5. Ops. nf/v1/health per store including outbox lag; frontend features gated on nf/v1/capabilities.

Cache and header mechanics (the price of Next)

  • Next sets Cache-Control: private, no-store on dynamic pages unless a header is already present, so Cache-Control is owned by next.config.ts headers() (path patterns per route group) and never set in components. Root layout is dynamic = 'force-dynamic'.
  • Client navigation requests the same path with ?_rsc=<hash> and relies on Vary, which Cloudflare ignores for non-image responses; the cache key therefore includes _rsc, and the Phase 0 gate checks that ?_rsc= is cached separately as text/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/revalidate does 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.

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

  1. WP renders no public HTML except order-pay and the order-received hop on the admin host.
  2. Money is computed only in WooCommerce; the frontend formats.
  3. Cache key (host, path, _rsc); cached HTML is always the complete generic page; (store) never reads request state.
  4. No country conditionals in shared code.
  5. Plugin list is an allowlist.
  6. No cutover with a dirty verify:*.
  7. Monorepo never blocks render.
  8. Browser never holds an API key or Cart-Token in JS; webhooks signature-checked.
  9. Every visible-data change emits a domain event.
  10. Shopper IP/UA reach WooCommerce on every order.