Blog with SanityCloudflare

Sanity Blog

A Sanity blog with draft mode and the Studio.

cms-sanity

Vercel's Blog with Sanity CMS — posts, authors and settings in a Sanity dataset read over GROQ with next-sanity, draft mode with live preview through the Presentation tool (unchanged in code; not yet exercised in a browser — risk CS-H), Sanity TypeGen, AI Assist and Unsplash presets, and the Sanity Studio embedded at /studio — running on Cloudflare Workers + Static Assets through @opennextjs/cloudflare, and packaged as an Orun baseline: one command takes a new blog from nothing to live on stage and prod, in its own repository, under its own name, in ONE landing — before its owner has a Sanity project at all.

The template's code is the code and Sanity stays the SaaS it is. What the port adds is the Workers build, the monorepo wrapper, a test package and the deploy contract; what it changes is what Workers force: the blog renders on request against Sanity's API CDN instead of ISR on a read-only cache, the Studio mounts client-side so it ships as static assets and not inside the Worker, and Speed Insights becomes the Cloudflare Web Analytics beacon. What the baseline adds is everything needed to make a copy of it somebody else's — built the way cirrus-baseline builds its product since baseline-v22: one landing, one convergence — and what makes it deployable with no Sanity project: an unconnected build renders bundled sample posts under a visible "connect your Sanity project" notice instead of failing. See specs/epics/cms-sanity for the design, the milestones and the as-built status.

Instantiating a blog

git clone --depth 1 --branch <baseline-tag> https://github.com/sourceplane/cms-sanity
cd cms-sanity
orun new --blueprint repo-blueprint.yaml --out ~/blogs/acme-notes --run-hooks --resume \
  --set reponame=acme-notes --set productname="Acme Notes" \
  --set githuborg=acme-inc --set subdomain=<workers-dev-subdomain>

Or, once the platform's registry lists it, from the Orun console (Baselines → cms-sanity → Build). Two phases — 01-product (the repository, its CI and the whole blog in one pull request, deployed to stage and prod by one convergence, smoke-tested) and 02-docs (the live deployment recorded) — each landing one pull request in the new repository. BOOTSTRAP.md is the operator guide; docs/phases/ has a page per phase.

The new repository receives the blog only — the app, its tests, its CI, its tooling, a README of its own and its operating contract — renamed throughout to the blog's name, repository and Cloudflare Worker. None of this repository's machinery ships, and nothing that ships names it.

The only provider the bootstrap asks for is Cloudflare. Sanity is not a provider the platform brokers, so the blog is built without it and its owner connects a project afterwards: two optional Orun secrets (SANITY_PROJECT_ID, SANITY_DATASET) and a redeploy, the Worker URLs in the project's CORS origins, and — for draft mode only — a Worker secret SANITY_API_READ_TOKEN. The blog's README, "Connecting Sanity" (.baseline/product/README.md here), has the steps.

What makes it a baselineWhere
The console card (inputs, secrets, programme)blueprint.yaml
The build document (modules, phases, hooks)repo-blueprint.yaml
This tree's identity, and the bootstrap's task contracts.baseline/
The rename enginetooling/rebrand/rebrand.mjs
The bootstrap's helpers (deferrals, redeploy marker, work-tree strip)tooling/bootstrap/
The deployment-record rendererhooks/render-deployment-docs.sh
The blog's README and context pack.baseline/product/README.md, ai/context/
The baseline's tests: contract, leak, offline bootstrap, end to endtesting/, .github/workflows/baseline.yml
Cutting a baseline-vN tag.github/workflows/tag.yml

What is here (the blog)

SurfaceWhere
Home, /posts/[slug], the draft-mode banner and action, the unconnected noticeapps/web/app/(blog)
The Studio at /studio (or, unconnected, how to connect)apps/web/app/(sanity), apps/web/sanity.config.ts
Draft mode enable routeapps/web/app/api/draft-mode/enable/route.ts
Client, queries, sanityFetch, image URLs, the project configurationapps/web/sanity/lib
The sample posts an unconnected blog rendersapps/web/sanity/lib/sample.ts
Schemas, Studio plugins, generated typesapps/web/sanity/schemas, apps/web/sanity/plugins, apps/web/sanity.types.ts
The blog's name (the rename's seam)apps/web/app/site.ts
Workers build and deploy contractapps/web/next.config.ts, open-next.config.ts, wrangler.jsonc, component.yaml
Teststests/web (vitest)

Getting started

pnpm install
pnpm typecheck
pnpm lint
pnpm test
pnpm build            # typegen + next build + opennextjs-cloudflare build → apps/web/.open-next
pnpm --filter @app/web preview   # wrangler dev over the built Worker: the sample posts, unconnected

That much needs no Sanity project: the baseline's own e2e tier (testing/e2e.test.sh) runs exactly this on each landing before a tag, and a blog's developers run it before a push — a blog's pull request builds and dry-deploys it, and does not lint or run the tests. To see your own content:

cd apps/web
cp .env.example .env.local
pnpm run setup                    # sanity init: picks or creates the project and dataset, writes .env.local
# a Viewer token from manage.sanity.io → API → Tokens, as SANITY_API_READ_TOKEN in .env.local (draft mode only)
cd ../..
pnpm --filter @app/web dev        # http://localhost:3000, the Studio at /studio

For the Worker locally, the project id and dataset are read at build time and the token is a Worker secret:

cd apps/web
cp .dev.vars.example .dev.vars    # SANITY_API_READ_TOKEN=<the same Viewer token>
NEXT_PUBLIC_SANITY_PROJECT_ID=<id> NEXT_PUBLIC_SANITY_DATASET=<dataset> pnpm build
pnpm preview                      # wrangler dev over the built Worker
VariableWhere it is readWhat it does
NEXT_PUBLIC_SANITY_PROJECT_IDbuild time (next build inlines it into the server bundle and the Studio)The Sanity project. A deployed build takes it from its environment's SANITY_PROJECT_ID secret, best-effort. Unset, the blog is unconnected: the sample posts under a notice, a connect page at /studio, draft mode refused with 401.
NEXT_PUBLIC_SANITY_DATASETbuild timeThe dataset (SANITY_DATASET); production when unset.
SANITY_API_READ_TOKENrequest time, in the WorkerA Viewer token; draft mode and live preview fetch drafts with it. A Worker secret in stage/prod, .dev.vars locally, .env.local for next dev. Optional: without it the blog renders and draft mode answers 401 with the reason.
NEXT_PUBLIC_CF_BEACON_TOKENbuild timeOptional. Cloudflare Web Analytics token (dashboard → Web Analytics → add a site). Unset, no script is rendered.

How it maps to Vercel

VercelHere
Next.js hosting (ISR pages, route handler, server action, Studio page)One Cloudflare Worker (.open-next/worker.js) + Static Assets; / and /posts/[slug] render on every request (force-dynamic), /studio is a pre-rendered shell from the static-assets cache
Project env vars in the dashboardBuild time: project id and dataset from optional Orun secrets, inlined by next build. Run time: the read token as a Worker secret
A missing env var fails every routeAn unconnected blog: sample posts, a notice, a connect page — deployable before a Sanity project exists
Time-based ISR (revalidate: 60)Sanity's API CDN, same 60 s TTL; nothing is pre-rendered or revalidated in the Worker
Draft mode / Presentation live previewUnchanged in code (/api/draft-mode/enable, cookies, VisualEditing); not yet exercised in a browser against a Sanity project — risk CS-H
The Studio rendered on the serverLoaded client-side only; megabytes of static chunks, a small Worker
Vercel Speed InsightsCloudflare Web Analytics beacon (app/(blog)/analytics.tsx)
Deploy on pushOrun deploys <repo>-web-{stage,prod} on merge to main and smoke-tests it (apps/web/component.yaml)
"Deploy with Vercel" (clone the template)orun new --blueprint repo-blueprint.yaml — a new repository, branded, deployed

CI

WorkflowWhat it runsShips to a blog
ci.ymlthe Orun pipeline, and the only workflow a blog carries: orun plan --changed, then one job per lane. A pull request runs the verify lanes (the blog built and dry-deployed per environment; they do not lint or run the tests), except the bootstrap's own landings (orun/BASE-*), where plan alone is the check; a merge deploys stage → prod. It plans nothing while intent.yaml names the placeholder workspace ws_CHANGE_ME, as this repository does. A manual run (Orunbase's Deploy button, or Run workflow on main) plans the environment and components it names under the push-to-main trigger, so they deploy as a merge deploys themyes
baseline.ymlthe baseline's own tiers: contract tests and the component docs' drift check, the blueprint read by the pinned orun, the leak gate, the offline bootstrap, each landing installed, its fixture rendered, type-checked, linted, tested and built, then the blog built in a staged job tree, dry-deployed and served with no Sanity projectno
tag.ymlcuts a baseline-vN tag (manual)no