Drupal Marketing SiteCloudflare

Drupal Marketing Site

A Drupal marketing site, cached in R2. Sample content until connected.

cms-drupal

Vercel's Drupal Marketing Site — next-drupal's example-marketing: a Next.js marketing site read from a headless Drupal over JSON:API, with landing pages built from Drupal paragraphs (hero, feature, cards, FAQ, a view), a blog two to a page, a properties listing with a filterable view, Drupal's menus, English and Spanish, on-demand revalidation when an editor saves and Drupal's preview links for drafts — running on Cloudflare Workers + Static Assets through @opennextjs/cloudflare, with every rendered page in an R2 bucket provisioned by Terraform through Orun, and packaged as an Orun baseline: one command takes a new site from nothing to live on stage and prod, in its own repository, under its own name — in ONE landing.

A fresh build needs no Drupal site: until its owner connects one, every page renders from a bundled sample Drupal (the template's own content model: landing pages from paragraphs, articles, a basic page, twelve property listings, two menus, both languages) under a "No Drupal site is connected" notice — never a 500. The owner connects Drupal by setting its URL (and the OAuth client preview reads drafts with, and the revalidation secret) as Orun secrets and redeploying. The Worker is about 1.3 MiB gzipped, inside Workers Free's 3 MiB. See specs/epics/cms-drupal for the design, the milestones and the as-built status.

Instantiating a product

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

Or, once the platform's registry lists it, from the Orun console (Baselines → cms-drupal → Build). Two phases: 01-product creates the repository and lands the whole product as ONE pull request — intent, CI, tooling, the R2 root, the site and its tests — and one convergence applies the R2 bucket on stage and prod and then deploys the site bound to it; 02-docs records the live deployment from probed facts. BOOTSTRAP.md is the operator guide; docs/phases/ has a page per phase.

The new repository receives the product only — the app, its R2 root, its tests, its CI, its tooling, a README of its own and its operating contract — renamed throughout to the product's name, repository, Cloudflare Worker and bucket names. None of this repository's machinery ships. Its owner then connects a Drupal site with its Orun secrets and a redeploy (the product's README, "Connecting Drupal"); the card asks for nothing of Drupal's, because the platform does not broker it.

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 product's README and context pack.baseline/product/README.md, ai/context/
The baseline's tests: contract, wiring, leak, offline bootstrap, end to endtesting/, .github/workflows/baseline.yml
Cutting a baseline-vN tag.github/workflows/tag.yml

What is here (the product)

SurfaceWhere
The pages: one catch-all route Drupal's decoupled router resolves (/, /about, /properties, every article, /es/…), the blog listing, and the routes Drupal calls — /api/revalidate, /api/preview, /api/exit-preview — plus /api/healthapps/web/pages
The site's nameapps/web/site.ts (SITE_NAME)
The Drupal client, and what it reads before a Drupal site is connectedapps/web/lib/drupal.ts, apps/web/lib/sample-drupal.mjs (served to the browser's properties search by pages/api/sample-drupal)
The template's components (paragraphs, nodes, the properties view, menus, the locale switcher)apps/web/components
Workers build, cache binding and deploy contractapps/web/next.config.ts, open-next.config.ts, wrangler.template.jsonc + wiring.fixture.json (→ the committed wrangler.jsonc), component.yaml
The R2 bucket (stage, prod)infra/terraform/cloudflare-r2
The wrangler renderer, and the step that puts the OAuth client and the revalidation secret on the Workertooling/wire
Teststests/web (the sample Drupal through next-drupal itself, a connected Drupal over HTTP — fake-drupal.mjs, the stand-in the e2e also runs — the two routes Drupal calls, the name seam, the multi-site filter)

Getting started

pnpm install
pnpm run wire:fixture   # render apps/web/wrangler.jsonc from the offline fixture
pnpm typecheck
pnpm lint
pnpm test
pnpm build              # next build + opennextjs-cloudflare build → apps/web/.open-next
pnpm --filter @app/web dev        # http://localhost:3001, Next's dev server
pnpm --filter @app/web preview    # wrangler dev over the built Worker and a local R2 bucket

With no DRUPAL_BASE_URL both serve the sample Drupal. To run against a Drupal site, put its settings in apps/web/.env.local (from apps/web/.env.example). Offline, the stand-in Drupal the tests use answers the site's calls with the same sample content over HTTP, plus OAuth, preview validation and drafts: node tests/web/fake-drupal.mjs --port 4040 --client c --client-secret s --preview-secret p, then DRUPAL_BASE_URL=http://127.0.0.1:4040 for the build and DRUPAL_CLIENT_ID=c DRUPAL_CLIENT_SECRET=s DRUPAL_REVALIDATE_SECRET=r in apps/web/.dev.vars.

How it maps to Vercel

VercelHere
Next.js hosting (Pages Router SSG, API routes, i18n routing)One Cloudflare Worker (.open-next/worker.js) + Static Assets (ASSETS)
getStaticPaths with fallback: "blocking"The same: every path Drupal lists is pre-rendered at build; a path Drupal adds later renders on its first visit and is kept
ISR cacheThe R2 bucket bound as NEXT_INC_CACHE_R2_BUCKET, keyed by build
res.revalidate(path) in /api/revalidateThe same route; OpenNext sends Next's self-request through the Worker's WORKER_SELF_REFERENCE service binding (a Worker cannot fetch its own workers.dev hostname)
Preview mode (drupal.preview, setPreviewData)The same, unchanged: Drupal's next module validates the link's secret and the draft is read with the OAuth client's token
Image OptimizationOff (images.unoptimized): images come from Drupal's files
NEXT_PUBLIC_DRUPAL_BASE_URL, DRUPAL_SITE_IDOrun secrets DRUPAL_BASE_URL and DRUPAL_SITE_ID (optional build env) — and, with no URL, the bundled sample Drupal instead of a build that cannot fetch
DRUPAL_CLIENT_ID, DRUPAL_CLIENT_SECRET, DRUPAL_REVALIDATE_SECRETOrun secrets each deploy puts on the Worker; the build never sees them. Without the client preview cannot read drafts; without the secret /api/revalidate refuses every call
NEXT_PUBLIC_BASE_URLEach environment's SITE_URL, else its workers.dev URL, set by the build as NEXT_PUBLIC_SITE_URL
Deploy on pushOrun applies the bucket and deploys <repo>-web-{stage,prod} on merge to main (apps/web/component.yaml)
"Deploy with Vercel" (clone the template)orun new --blueprint repo-blueprint.yaml — a new repository, branded, deployed

The port moved the Next 12-era template to Next 16.3.8 (React 19, next-drupal 2.0.1, TanStack Query 5, the current next/link and next/image); the epic's IMPLEMENTATION-STATUS lists every departure.

How the bucket reaches the Worker

infra/terraform/cloudflare-r2 ──apply (r2-data token)──► orun secret WIRING_CLOUDFLARE_R2 (stage, prod)
                                               │   (each deploy resolves it as it starts;
                                               │    a missing one fails the lane)
                                               ▼
apps/web  dependsOn cloudflare-r2 (enforced on main) · secretEnv WIRING_CLOUDFLARE_R2_<ENV>
  build:  tooling/wire/live.mjs → wrangler.jsonc with the environment's bucket name
          (dev, verify-only, renders the fixture) → next build → opennextjs-cloudflare
          → tooling/wire/worker-secrets.mjs puts DRUPAL_CLIENT_ID, DRUPAL_CLIENT_SECRET,
            DRUPAL_REVALIDATE_SECRET on the Worker
  deploy: workers-deploy token → <repo>-web-<env>, bound to its bucket and to itself

wrangler.jsonc is committed as the fixture render of wrangler.template.jsonc, because the deploy lane's verify-app-structure step checks the config exists before it builds; testing/e2e.test.sh fails if the two disagree.

CI

WorkflowWhat it runsShips to a product
ci.ymlthe Orun pipeline, and the only workflow a product carries: orun plan --changed, then one job per lane. A pull request runs the verify lanes (the R2 root planned for stage and prod, the site built and dry-deployed per environment; they neither lint nor run the tests, which baseline.yml's e2e tier runs before every tag), except the bootstrap's own landings (orun/BASE-*), where plan alone runs and nothing waits for it (the landing checked its commit in the box); a merge applies the R2 bucket, then 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, the wiring path, the blueprint read by the pinned orun, the leak gate, the offline bootstrap with the product's own plan, the product built in the job tree, dry-deployed and served as born (the sample Drupal), then built again with a stand-in Drupal connected and browsed — its cache reused (no Drupal read on a second visit), an edit shown only after the secret-checked revalidation, and a draft opened through Drupal's preview linkno
tag.ymlcuts a baseline-vN tag (manual)no