TLTan LeBackend & automation · CalgaryLet’s talk
← All posts

Moving a React app on Azure Static Web Apps to a Cloudflare custom domain

Moving a React app on Azure Static Web Apps to a Cloudflare-managed custom domain without exposing an unfinished site, and everything else the hostname touched: CORS, email and Stripe redirects.

How we moved a React single-page app hosted on Azure Static Web Apps onto a custom domain managed in Cloudflare, without exposing an unfinished site, and what else had to change behind the scenes: CORS, transactional email, and Stripe redirects.

Background

Our product is a SaaS tool for domain-name research. The frontend is a React app deployed to Azure Static Web Apps through GitHub Actions. The backend is an ASP.NET Core API on Azure App Service. Until recently the frontend lived on the default *.azurestaticapps.net hostname that Azure hands out.

The business made two decisions at once: the public domain would be a .ai domain already sitting in a Cloudflare account, and the brand would change to match it. So the job was part DNS, part configuration, and part find-and-replace.

What “moving the site” actually touches

Pointing DNS at the new host is the easy part. The frontend origin is baked into several other places, and if any of them is missed the app breaks in a way that looks like a bug rather than a DNS problem.

ComponentWhy it cares about the frontend hostname
App Service platform CORSIntercepts the browser preflight before the API code runs. An unknown origin gets a 400.
ASP.NET Core CORS policyA second, independent allow-list read from configuration. Both layers must agree.
Stripe Checkout and Customer PortalSuccess, cancel, and “return to site” URLs are generated server-side from a base URL setting.
Transactional emailPassword-reset and invitation links use the same base URL, and the sender address must be on a verified domain.
Brand stringsHeader, login page, browser title, attribution text, email signatures.

Lesson one: before touching DNS, grep the backend for every place the frontend URL is read. In our case it was a single FrontendBaseUrl setting with a fallback to “the first CORS origin”. That fallback is convenient in development and a trap in production.

Step 1: Audit the Cloudflare zone first

The domain was already in Cloudflare with DNS setup mode Full. Before adding anything we did three things:

  1. Exported the zone (DNS, Records, Export) as a BIND file and kept it outside the repository. That is the rollback plan.
  2. Checked the SSL/TLS mode. It was Full. Azure Static Web Apps needs Full or Full (strict). Flexible mode causes redirect loops because Cloudflare talks to the origin over plain HTTP.
  3. Read every existing record. The zone contained only three: an MX, an SPF TXT, and a DKIM TXT, all belonging to our email provider (Resend). No apex A record, no www, no mailbox MX. In other words, nothing live to break, and the sending domain was already verified.

If the zone had contained Google Workspace MX records or an existing SPF record, those stay untouched. SPF must remain a single TXT record; you merge include: entries rather than adding a second one.

Step 2: Stage on a subdomain

We did not want the real hostname to show a half-finished app while we tested. So the first binding was beta.<domain>.

Cloudflare side

Type:    CNAME
Name:    beta
Target:  <your-app>.azurestaticapps.net
Proxy:   DNS only (grey cloud)
TTL:     Auto

The grey cloud matters. Azure validates ownership by resolving the CNAME and then issues a free managed certificate. If Cloudflare is proxying, Azure sees Cloudflare’s IPs instead and validation stalls.

Azure side

Static Web App, Custom domains, Add, “Custom domain on other DNS”, enter beta.<domain>, record type CNAME, Add. Ours went from Validating to Ready in under ten minutes, certificate included.

API side

Both CORS layers, then a restart so the .NET process re-reads configuration:

# Layer 1: App Service platform CORS
az webapp cors add -n <api-app> -g <resource-group> \
  --allowed-origins https://beta.<domain>

# Layer 2: application config (ASP.NET Core reads Cors:AllowedOrigins[])
az webapp config appsettings set -n <api-app> -g <resource-group> \
  --settings "Cors__AllowedOrigins__1=https://beta.<domain>"

az webapp restart -n <api-app> -g <resource-group>

Verification is a single preflight request. If the response carries the new origin back, both layers are in place:

curl -s -o /dev/null -D - -X OPTIONS https://<api-host>/api/auth/login \
  -H "Origin: https://beta.<domain>" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type" \
  | grep -i access-control-allow-origin

Step 3: The Stripe surprise

With the beta host live we opened the Stripe Customer Portal and clicked “Return to site”. It sent us back to the old azurestaticapps.net hostname. Not broken, but wrong: the two origins do not share a login session, so the user would land on a login screen.

The cause was the fallback mentioned earlier. FrontendBaseUrl was blank, so the API fell back to the first CORS origin, which was still the old host. One setting fixed Stripe redirects and email links together:

az webapp config appsettings set -n <api-app> -g <resource-group> \
  --settings "Billing__FrontendBaseUrl=https://beta.<domain>"

The Stripe webhook itself needed nothing. It points at the API host, which did not move.

Lesson two: a “return URL” is part of your deployment topology. Make it an explicit setting and treat any fallback as a development-only convenience.

Step 4: An “under construction” page on the real hostname

The apex had no records, so visitors typing the domain got a Cloudflare error page. We wanted something branded there without pointing it at the app yet.

Cloudflare’s free tier solved this in about five minutes. In Workers & Pages we created a project by uploading a folder containing a single index.html: brand name, one line of copy, and a <meta name="robots" content="noindex"> tag so search engines ignore it. Then, in the project’s Domains tab, we added the apex and www. Because the zone is in the same Cloudflare account, the DNS records and certificates were created automatically.

The result: the apex shows a holding page, beta shows the real app, and the two are completely independent. Cutting over later means deleting the domain from the Worker project and adding a CNAME to Azure. The holding page can be kept around as a maintenance page.

Step 5: The rebrand in code

This part was mundane but worth doing carefully. A search for the old brand string across both repositories found every occurrence in about a minute. Things that changed:

  • Application header: short brand name, no TLD suffix.
  • Login and signup titles, browser tab title.
  • Data-attribution text on the About page. The license wording itself stayed exactly as required.
  • Email sender display name and a signature line on reset and invitation emails.

Code comments that referred to the company by name were left alone. They are not user-facing, and churning them adds noise to the diff.

Cut-over checklist

When the product owner gives the go-ahead, the remaining steps are short:

  1. Remove the apex (or app) domain from the holding-page Worker project.
  2. Add a grey-cloud CNAME for the final host pointing at the Static Web App.
  3. Add the domain in Azure Custom domains and click Set default, so the old azurestaticapps.net hostname redirects.
  4. Add the final origin to both CORS layers.
  5. Point FrontendBaseUrl at the final host and restart the API.
  6. Announce the new URL. The old one keeps working via redirect.

Things that bit us, in one list

  • Two CORS layers. App Service platform CORS and the framework’s own policy are independent. Missing either produces a preflight failure that looks identical from the browser.
  • Grey cloud during validation. Azure cannot validate a proxied CNAME.
  • Configuration is not live until restart. App settings changes on App Service require a restart for the .NET configuration provider to pick them up. Expect a cold start of thirty seconds or so.
  • Return URLs are topology. Anything that generates links back to the frontend (payments, email) needs the new host, and needs it explicitly.
  • Account-level vs zone-level menus. In the Cloudflare dashboard, Workers & Pages lives at the account level, not inside the domain’s menu. “Back to Domains” first.

Total effort

DNS and Azure binding: under an hour including validation waits. API configuration and verification: fifteen minutes. Holding page: five minutes. Rebrand in code: about the same. The longest part was the audit at the start, and it was the part that made everything after it uneventful.

First published on tanldt.blogspot.com on Sep 10, 2026.