Salesforce Platform Architect Portfolio

Governance, Architecture, and Living Documentation

ADR-041: Headless Static Front End on Cloudflare Pages

Status

Accepted, cutover pending (2026-11-02). The static site builds in CI and deploys a preview of each pull request to the Cloudflare Pages project ryanbumstead. Its production *.pages.dev address gets its first deployment once I set up the build user, and until I cut over that address is the only place it is reachable. ryanbumstead.com still redirects to the Experience Cloud site. This ADR becomes Built when the cutover runbook (docs/guides/cutover-runbook.md) is done and two weeks pass with no rollback.

Context

Three things pushed me to look at a different front end.

  1. A licensing question. Salesforce support told me that a public website served from a Developer Edition org may count as production use, which that edition does not allow. That question is not settled, and I do not treat the launch as cleared. I am not putting the support case number or the people involved in the repository.
  2. A limit I cannot watch. The Experience Cloud site’s guest usage counters read 0 in the org, so I cannot see how close the site is to the guest limits. A front end that reads the org on a schedule, and not once per visitor, makes the question smaller.
  3. A speed ceiling. The LWR shell holds mobile LCP at 6.5 to 8.3 s on the live site (Lighthouse CI 0.15.1, mobile profile, 2026-11-02), against a target of 2.5 s (Technical Guide §2.11). The cost is the network and the framework scripts that load before the first site component renders, not the device. Every route is a client render of an empty shell, and no change to my own files moves that. On a Cloudflare preview with sample fixture data the same profile gave median LCP of 2.13 to 2.94 s across six routes, and five of the six met 2.5 s (SAS §2.1).

Decision

The public front end becomes static pages on Cloudflare Pages, built from the same Lightning Web Components and from data the Salesforce org exports. Salesforce stays the source of truth. The few features that need a request at view time go through Pages Functions that call Salesforce from the server, each behind a kill switch.

  1. One source of components. web/ compiles the existing LWCs with LWC OSS. There is no rewrite. Platform imports (@salesforce/apex, lightning/uiGraphQLApi, lightning/navigation and the base components I use) resolve to small shims in web/src/shims. Apex methods answer from data read at build time, GraphQL wires answer from queries run at build time, and a request time call goes through one seam, setRequestHandler, to a Pages Function.
  2. An allowlisted export. SiteExportResource (GET /services/apexrest/site/export) runs one of 26 read only controller methods named in SiteExportCalls and returns its result with null fields left out. Anything not on the list is a 400 and runs nothing. Only a user holding the Site_Build_Export custom permission can call it. The static build signs in as a dedicated build user (Portfolio_Guest plus Portfolio_Site_Build, on the Portfolio API Integration profile), not as the CI administrator, because that administrator can read testimonials the guest never sees.
  3. An export parity guarantee. The static pages must show what the guest would have been shown. SiteExportResourceTest runs every allowlisted call as the guest and as the build user and compares the results. SiteExportTestimonialParityTest does the same for the testimonial reads, which depend on the guest sharing rule and the restriction rule, and its method graphqlRowsMatchTheGuest compares the skills and gallery GraphQL rows read with sharing applied, as the guest and as the build user. A planned read with no data fails the build, a leak scan checks the output, and web/build/check-export-coverage.mjs checks that the allowlist is exactly the set of build time calls the public pages import.
  4. Functions for the features that need a request. The contact form, the testimonial form, the cover letter, the API Lab and the evaluator token form, the footer readings and the pipeline status call Salesforce through Pages Functions that sign in with a client credentials token for an edge user and call /services/apexrest/site/actions/<action>. No browser calls the Salesforce site as a guest. The browser’s request is checked at the edge first. Turnstile guards the two forms, and the visitor’s IP is hashed at the edge with a secret salt before Salesforce sees it, so SubmissionGuard can block and rate limit by hash without storing an address.
  5. A kill switch for each feature. FEATURE_CONTACT, FEATURE_TESTIMONIAL, FEATURE_COVER_LETTER, FEATURE_API_LAB and FEATURE_LIVE_READS are each live or recorded. Production is decided by the request host, and on a production host a switch that is not set reads as live, so all five are set to recorded in [vars] in web/wrangler.toml and I flip them there at cutover. That file is the project’s source of truth for plain variables, and a deploy drops any text variable set only in the dashboard. Any other host is always recorded. In recorded mode a form is replaced by the contact call with my public profile links, the cover letter shows a labelled sample, the API Lab shows responses captured at build time, and the live readings show the build snapshot only.
  6. Honest labels. Only a section that really calls Salesforce at view time says “Live from Salesforce”. A section read at build time says when it was built.

Alternatives considered

Option Why not
Stay on the LWR site It leaves the licensing question open and the LCP at 6.5 to 8.3 s. Every change I measured on it moved LCP by less than the run to run spread (Technical Guide §2.11).
LWR server side rendering Salesforce offered it for Build Your Own (LWR) sites as Experience Delivery, a beta with server side rendering and its own CDN. That beta is discontinued as of Winter ‘27, and a site hosted on it moves back to the standard LWR runtime when it is next published (LWR Sites developer guide, “Experience Delivery (Beta)”, read 2026-11-03). So there is no pre rendered mode to switch on. It would also keep the site inside the Developer Edition org, so it would not answer the licensing question.
A framework rewrite (React, Astro or similar) It would give two copies of every component, one for the Salesforce site and one for the static site, and the View source tab, the tests and the docs would describe code that no longer runs. The site is a Salesforce architect’s portfolio, and its components are part of the evidence.
LWC OSS reuse of the existing components (chosen) The same files compile for both hosts. Platform imports go through shims, so a component never learns which host serves it, apart from c/siteHost, which tells it when the copy should differ. The Salesforce site keeps working until cutover, so there is a rollback.

Implications

  • Two hosts until cutover. The Salesforce site keeps deploying as before. The static site builds from the org in a separate workflow (site.yml), on every pull request that touches the site, after every production deploy, every six hours and by hand.
  • Data is as old as the last build. A section read at build time shows the time of the read, in UTC and in the visitor’s own zone. The six hour schedule bounds the age. A record I edit appears at the next build.
  • Two identities in Salesforce, not one. The build user reads the export. The edge user (site-edge@ryanbumstead.com) holds Portfolio_Site_Edge and calls the site actions. The build user’s export permission set grants no object or field access. The edge user’s permission set grants only what the seven actions need: the Site_Edge_Actions custom permission, the SiteActionsResource class, create and read on Testimonial__c with the seven fields the guest can edit, read on UserExternalCredential, and the three external credential principals the guest holds. The permission set and the Apex are built and tested in a scratch org. The user itself does not exist in production yet. Security matrix: SAS §4.6.
  • Cover letters keep running on Agentforce. The edge user must not fall back to the template every time. The cover letter action posts to /papi/ai/generate through the Portfolio_API Named Credential as the integration user, as it does today, so the edge user holds the Portfolio_API and Gemini principals (and the token principal) and not Prompt Template User, which would need a licence. The scratch org proves this with stubs only. That the letter stays on Agentforce in production, and does not fall back to the template, is [unverified] until I run the cover letter check once the edge user exists.
  • Lightning Out is not used. The cover letter page does not embed a Salesforce component in the static page.
  • The cache warmer goes away. The portfolio-cache-warmer Worker pings https://ryanbumstead.com/ every five minutes, which today means guest traffic to the Salesforce site. It is paused before cutover and deleted when the static site is the front door (Technical Guide §2.13).
  • Turnstile and the host share a vendor. The contact and testimonial forms use Cloudflare Turnstile, and the static site runs on Cloudflare. The third party trade in ADR-031 changes accordingly.

Open question

The static pages and most functions do not touch the org when a visitor loads a page, but the live readings, the forms, the cover letter and the API Lab still call it. Public API traffic from a Developer Edition org still uses that org’s daily API request quota, which the footer shows. Whether a public site that consumes the quota this way is allowed is a question only Salesforce sales can answer, and it is still open. The kill switches let me move every feature to recorded in minutes if the answer is no. The static front end reduces the load on the org and gives me a way to leave it. It does not settle the licensing question, and I do not claim that it does.

Rollback

Nothing in Salesforce changes at cutover, so the rollback takes minutes. I turn the Cloudflare Page Rule that forwards ryanbumstead.com to the Salesforce site back on and remove the custom domain from the Pages project. The Salesforce site never stopped deploying, so it answers as before. For two weeks after cutover I keep the Salesforce site up and the Page Rule ready, turned off but not deleted. After that I decide whether to take the Salesforce site offline. The API site that vends evaluator tokens stays either way. The steps are in the cutover runbook.

ADR-001, ADR-014, ADR-016, ADR-021, ADR-027, ADR-031