Deploy to a Cloudflare Worker

cloudflareWorker() from @pagedeck/adapter-cloudflare-worker compiles the site's routing into a Worker that serves the site from an R2 bucket. You upload the site to the bucket and publish the Worker in front of it. The Worker reads the bucket's manifest.json on each request, so a deploy or a rollback goes live without publishing the Worker again. Deploy a site covers what every host shares: the plan pagedeck diff writes, the wait before a prune, and rolling back.

This adapter has not yet served a site in production. Test it on a staging Worker before you rely on it.

Name the adapter

npm install @pagedeck/adapter-cloudflare-worker
import { defineConfig } from "@pagedeck/core";
import { cloudflareWorker } from "@pagedeck/adapter-cloudflare-worker";

export default defineConfig({
  collections: [],
  build: {
    pages: [],
    components: {},
    adapter: cloudflareWorker(),
  },
});

pagedeck build writes edge/worker.js, beside site/. It is not part of the upload: you publish it as a Worker. Its redirects and header rules are in the script, so publish it again whenever the build changes it.

Upload the site to R2

Create a bucket, then upload site/ to it. A file's key is its path in site/ with no leading /: site/about/index.html is stored as about/index.html. R2 takes S3's API (S3 API compatibility), so aws s3 sync uploads the directory with an R2 API token as its credentials. Upload manifest.json last:

npx pagedeck sync
npx pagedeck build
aws s3 sync site/ s3://my-site/ --exclude manifest.json \
  --endpoint-url "https://$ACCOUNT_ID.r2.cloudflarestorage.com"
aws s3 cp site/manifest.json s3://my-site/manifest.json \
  --endpoint-url "https://$ACCOUNT_ID.r2.cloudflarestorage.com"

The Worker serves a path only when a row of the bucket's manifest.json names it, so a build's pages go live together, when its manifest lands. It sends each file with the Content-Type and Cache-Control the upload stored, and a header rule that sets either wins.

The sync above deletes nothing. Delete the files a new build no longer writes with the prune rows of the plan pagedeck diff writes, after the time the plan gives, as Deploy a site describes.

Publish the Worker

Bind the bucket to the Worker as PAGEDECK_ORIGIN. A wrangler.jsonc beside pagedeck.config.ts:

wrangler.jsonc:

{
  "name": "my-site",
  "main": "edge/worker.js",
  "compatibility_date": "2026-10-01",
  "r2_buckets": [{ "binding": "PAGEDECK_ORIGIN", "bucket_name": "my-site" }]
}

Then publish it:

npx wrangler deploy

Give the Worker a route or a custom domain in front of the site (routes).

Each isolate reads manifest.json at most once every 10 seconds, so a deploy or a rollback is served everywhere within 10 seconds of its manifest landing. Before the first upload every path answers 404. A manifest.json that is not a deploy manifest answers 503, and the Worker logs what is wrong with it.

Roll back

pagedeck rollback writes a plan whose upload rows are the earlier build's files that differ from the live build's, the pages among them. Upload those rows from the earlier build's output, then its manifest.json last, as a deploy does. The Worker serves it within 10 seconds, with no new wrangler deploy. Publish the Worker again only when the earlier build's edge/worker.js differs, because the routing changed between the two builds.

Route a prefix to another Worker

A site that declares build.routing.upstreams sends each prefix to an app, as Run an app beside the site describes. When the app is a Worker too, name a service binding for its prefix:

import { defineConfig } from "@pagedeck/core";
import { cloudflareWorker } from "@pagedeck/adapter-cloudflare-worker";

export default defineConfig({
  collections: [],
  build: {
    pages: [],
    components: {},
    adapter: cloudflareWorker({ upstreams: { "/api/": { binding: "APP" } } }),
    routing: {
      upstreams: [{ prefix: "/api/" }],
    },
  },
});

Then bind the app's Worker as APP in wrangler.jsonc:

{
  "services": [{ "binding": "APP", "service": "my-app" }]
}

The Worker hands every request for /api, or under /api/, to the app unchanged, whatever its method, and returns the app's answer with none of the site's header rules. A service binding needs no public URL for the app.

An app that runs elsewhere takes url on its upstream in place of the binding, and the Worker fetches it. Do not point url at a hostname in the Worker's own zone: such a fetch goes to the zone's origin server and skips any Worker routed there. Use a binding instead.