Architecture

Two-build split (public vs staging)

The White Tree Digital site ships as two separate Cloudflare Workers built from the same repo — a static, prerendered public site that visitors hit, and a server-rendered staging mirror that exists only for Sanity Studio's Presentation tool to iframe — and a single build-time environment variable, BUILD_TARGET, decides which one a build produces.

The two builds pull in opposite directions on purpose. The public site optimizes for resilience and speed: plain HTML files served from Cloudflare's edge, with Sanity removed from the request path entirely. Staging optimizes for live editing: full SSR that reads draft cookies at request time so click-to-edit works. One route file serves both; the only knob that differs is BUILD_TARGET. This page explains the mechanism. For the Cloudflare dashboard settings and the deploy table, see Deployment; for the editing workflow inside Studio, see Presentation Tool & draft mode.

Why two deployments

Two goals that can't be satisfied by one build:

  • Public = resilient + fast. Visitors get prerendered HTML served from Cloudflare's edge. Sanity is not in the request path, so a Sanity outage can't take the public site down. The trade-off: the public site only changes when you deploy.
  • Staging = live editing. Sanity's Presentation tool (click-any-element-to-edit, drafts visible as you type) needs server-side rendering that reads draft cookies at request time. That can't be static, so it gets its own always-live SSR deployment that only the Studio uses.

The public site is the priority — it's where leads come from — and making it static is what gives it the outage immunity. Staging is the supporting actor that makes editing pleasant without compromising the public build.

The two deployments at a glance

PublicStaging
URLwhitetreedigital.comstaging.whitetreedigital.com
Cloudflare Workerwhitetreedigital-publicwhitetreedigital
Build commandnpm run build:staticnpm run build
BUILD_TARGETstatic(unset → server)
Astro outputstatic (prerendered HTML)server (SSR)
Sanity at runtimenone — content pages are static filesevery request
Draft preview / Presentationoffon
Indexed by Googleyesno (noindex + robots Disallow)
Rebuilds whencode push or the Deploy buttoncode push (content is always live)

The build:static script is just the normal Astro build with the toggle set:

"build": "astro build",
"build:static": "cross-env BUILD_TARGET=static astro build"

Publish vs Deploy — the mental model

Conflating these two actions is the single most common point of confusion, so internalize the distinction:

Publish (Sanity, per-document)Deploy (the Studio "Deploy" tab)
What it doesSaves one document from draft → published in the datasetRebuilds the whole public site from current published content
ScopeOne documentEverything
SpeedInstant~1–3 minute rebuild
Shows up in Presentation previewInstantlyn/a (preview is always live)
Shows up on whitetreedigital.comNo — not until you DeployYes, when the build finishes

The everyday loop: edit content in Studio (Presentation preview, against staging, shows changes live as you type) → Publish each document when it's ready (instant, affects the dataset and the preview) → when a batch is ready for the world, click Deploy, and a couple of minutes later the public site reflects it.

This separation is intentional. You can publish and iterate freely without each save triggering a public rebuild, and you choose the exact moment the public site changes.

Publishing does not reach the public site

Because the public build is static, Publish ≠ Deploy. A published change (or a brand-new page doc) shows up instantly in Presentation preview but appears on whitetreedigital.com only after the next Deploy or code push. "Published a change, public site unchanged" and "new page 404s on public but works in preview" are both working-as-designed — the fix is to Deploy.

The BUILD_TARGET toggle

BUILD_TARGET is the whole switch. It's read once, at build time, in astro.config.mjs:

output: process.env.BUILD_TARGET === 'static' ? 'static' : 'server',
adapter: cloudflare(),

static produces the prerendered public build; anything else (including unset) produces the SSR staging build. Notice the Cloudflare adapter is always included — both builds keep their on-demand /api/* routes (more on this under "never go fully static" below).

The three mechanisms

The same route files serve both builds. Three mechanisms make that possible.

1. Output toggle

astro.config.mjs flips Astro's output between 'static' and 'server' on BUILD_TARGET, as shown above. That single line is what makes content pages prerender in the public build and render per-request in staging.

2. The getRouteClient client seam (dead-code elimination)

Every content route picks its Sanity client through getRouteClient(Astro.cookies) in website/src/lib/sanity-draft.ts, never by importing a client directly:

export async function getRouteClient(cookies: AstroCookies) {
  if (import.meta.env.BUILD_TARGET === 'static') return sanity
  return getSanityClient(cookies)
}

In the static build, BUILD_TARGET is inlined to 'static', so the draft branch — and the Astro.cookies read it would trigger — is dead-code-eliminated. The route compiles down to the published sanity client: no cookie access, no cloudflare:workers import anywhere in the prerendered graph. In the server build the condition is false, so it falls through to the cookie-aware getSanityClient, which returns the stega-enabled preview client when a valid draft cookie is present.

The reason this seam exists is a Workers-runtime import that must not leak into static output:

export async function getSanityClient(cookies: AstroCookies) {
  if (!isDraftMode(cookies)) return sanity
  const {env} = await import('cloudflare:workers')
  const token = env.SANITY_API_READ_TOKEN ?? import.meta.env.SANITY_API_READ_TOKEN
  return token ? sanityPreview.withConfig({token}) : sanityPreview
}

Keep the cloudflare:workers import dynamic

The import('cloudflare:workers') inside getSanityClient is dynamic on purpose, and only resolves inside the draft branch the static build never reaches. A top-level import {env} from 'cloudflare:workers' would drag the Workers runtime module into the build graph of every route that imports sanity-draft.ts — including the prerendered public routes, which must stay token-free. Don't reintroduce a top-level import in that file. Verify a static build is clean with grep -rc cloudflare:workers dist/client — it must be 0.

3. getStaticPaths per dynamic route

The four dynamic routes — [slug], services/[slug], blog/[slug], portfolio/[slug] — each export a getStaticPaths() that enumerates published slugs at build time via the get*Slugs() helpers in website/src/sanity/queries/ (getPageSlugs, getServiceSlugs, getPostSlugs, getPortfolioPostSlugs). This is what bakes each page into a file in the static build.

In the server build, getStaticPaths() is ignored and Astro logs a harmless getStaticPaths() ignored WARN — those routes are dynamic in SSR. That warning is expected, not a bug.

Adding a new content type with its own route?

Add a get*Slugs() query and a getStaticPaths() to its route, mirroring an existing one. Without it, new pages of that type won't prerender into the public build (and will 404 there even though they work in staging preview).

The Layout also gates two things on the build target so the static build never reads cookies and never ships the visual-editing overlay:

const draftMode = import.meta.env.BUILD_TARGET !== 'static' && isDraftMode(Astro.cookies);
const noindex =
  import.meta.env.PUBLIC_NOINDEX === 'true' || Astro.url.hostname.endsWith('.workers.dev');

Never go fully static

The public build is hybrid, not fully static — and that distinction is load-bearing for deploys.

/api/audit and /api/draft-mode/* keep export const prerender = false, so even the BUILD_TARGET=static build stays hybrid and still emits dist/server/ (with dist/server/wrangler.json) alongside dist/client/. That dist/server/wrangler.json is what wrangler deploy needs.

A fully-static build breaks deploy

If the public build ever goes fully static — i.e. no route keeps prerender = false — Astro stops emitting dist/server/, the wrangler.json disappears, and wrangler deploy fails with "config path … does not exist". Keep at least the /api/* on-demand routes as prerender = false. A clean static build has both dist/server/ and dist/client/.

Resilience: if Sanity goes down

This is the payoff of making the public site static.

ScenarioPublic siteStagingStudio
Sanity read CDN unreachableUnaffected — content pages are static files on Cloudflare; no Sanity call happens at request timeDegrades (reads Sanity live)Editing unavailable
Sanity down during a public buildThe build fails or serves stale; the last successful build keeps serving — nothing goes down
Cloudflare downDown (it's the host for both)DownOn Sanity's infra, unaffected

The only thing a Sanity outage costs you on the public side is the ability to deploy new content until Sanity recovers — the live site keeps serving its last build indefinitely. (Staging is the opposite: it reads Sanity live, so an outage degrades it. That's an acceptable trade because only the Studio uses staging.)

The "Deploy" tool and the deploy hook

The public site is rebuilt by a manual trigger, and the Studio surfaces it as a Deploy tab.

  • Where: top navigation in Studio, the Deploy tab (route /deploy), implemented in studio/components/DeployTool.tsx and registered via tools in studio/sanity.config.ts.
  • What it does: POSTs a Cloudflare Workers deploy hook that rebuilds the whitetreedigital-public Worker. Two-step confirm so it can't be fired by accident.
  • Configured by: the SANITY_STUDIO_DEPLOY_HOOK_URL env var in studio/.env.production, baked into the Studio bundle at npx sanity deploy. If it's blank, the tool shows a "not configured" notice.

The same hook can be fired without Studio: npm run deploy:public from website/ (it reads DEPLOY_HOOK_URL and POSTs it), or a direct Invoke-RestMethod -Method Post -Uri "<hook-url>", or the redeploy button in the Cloudflare dashboard.

Never put a real deploy-hook URL in committed files or docs

The deploy hook is a secret POST URL — anyone with it can trigger a production rebuild. It lives only in studio/.env.production (gitignored) as SANITY_STUDIO_DEPLOY_HOOK_URL and in your shell as DEPLOY_HOOK_URL. Document the variable name, never the value.

Why it's manual, not automatic: wiring Sanity's document webhooks to the hook would rebuild on every Publish — wasteful during iterative editing, and exactly the per-save churn the two-build model is designed to avoid. Keep it manual. And keep the tab named Deploy, deliberately not "Publish", to stay distinct from Sanity's per-document Publish action.

What triggers which rebuild

TriggerRebuilds public?Rebuilds staging?
Push code to mainyes (with latest published content)yes
Click Deploy in Studioyes
npm run deploy:public / POST the hookyes
Publish a document in Studionono (staging is already live)

Both Workers are git-connected via Cloudflare Workers Builds on main, so a code push rebuilds both. Content-only changes never push git, so they never auto-rebuild the public site — only an explicit Deploy or a code push does.

The public Worker pins its own name

The Cloudflare adapter derives the Worker name from website/package.json (whitetreedigital — the staging Worker). The public Worker's deploy command must pin a distinct name, npx wrangler deploy --name whitetreedigital-public, so it doesn't deploy on top of staging. If public and staging ever appear to be "fighting" over one Worker, this --name override is missing.

Where this lives

PathRole
website/astro.config.mjsBUILD_TARGEToutput toggle; Cloudflare adapter always included
website/src/lib/sanity-draft.tsgetRouteClient (build-aware seam) + getSanityClient (dynamic cloudflare:workers import)
website/package.jsonbuild / build:static (the cross-env BUILD_TARGET=static script) + deploy:public
website/src/pages/[slug].astro, services/[slug].astro, blog/[slug].astro, portfolio/[slug].astrothe four dynamic routes — each exports getStaticPaths()
website/src/sanity/queries/*.tsgetPageSlugs / getServiceSlugs / getPostSlugs / getPortfolioPostSlugs enumerators
website/src/layouts/Layout.astrogates draftMode and the noindex flag on BUILD_TARGET / PUBLIC_NOINDEX
website/src/pages/api/audit.ts, api/draft-mode/*the on-demand routes (prerender = false) that keep the build hybrid
studio/components/DeployTool.tsx, studio/sanity.config.tsthe Deploy tool and its registration
studio/.env.productionSANITY_STUDIO_PREVIEW_URL (→ staging) + SANITY_STUDIO_DEPLOY_HOOK_URL
_TWO-BUILD-OPERATIONS.md, website/CLAUDE.mdthe in-repo operations guide and authoritative project doc

Related pages: Deployment (Cloudflare Workers) for the dashboard settings and env mirror, Presentation Tool & draft mode for the draft-cookie handshake and stega, Environment variables for the full build-time vs runtime-secret split, and Monorepo architecture for how website/ and studio/ relate.

Previous
Monorepo architecture