www/docs/cloudflare-migration-steps.md
This document records all changes made to migrate the Medusa docs site from Vercel to Cloudflare Workers using @opennextjs/cloudflare. The migration was done in PR #15388 (commit 4e7034a0a0) with follow-up fixes on the docs/migrate-cloudflare branch.
Each of the 7 Next.js apps (book, resources, api-reference, ui, user-guide, cloud, bloom) is deployed as its own Cloudflare Worker (not Cloudflare Pages) using the @opennextjs/cloudflare adapter.
The book app continues to act as the central proxy — its next.config.mjs rewrites /resources/*, /api/*, /ui/*, /user-guide/*, /cloud/* to the respective Worker URLs via NEXT_PUBLIC_* env vars.
wrangler.jsonc (added to each app)Each app got a wrangler.jsonc in its root. Key fields:
"name": the Worker name (e.g. medusa-docs-book)"main": ".open-next/worker.js" — Worker entry point (not pages_build_output_dir)"assets.binding": "ASSETS" — valid in Workers (only reserved in Pages projects)"services" with WORKER_SELF_REFERENCE binding — required by @opennextjs/cloudflare"images" binding"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"]"env.production" with services and images duplicated (wrangler does not inherit these into environments){
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "medusa-docs-book",
"main": ".open-next/worker.js",
"compatibility_date": "2024-12-30",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": { "directory": ".open-next/assets", "binding": "ASSETS" },
"services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "medusa-docs-book" }],
"images": { "binding": "IMAGES" },
"vars": {
"CLOUDFLARE_ENV": "1"
},
"env": {
"production": {
"services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "medusa-docs-book" }],
"images": { "binding": "IMAGES" }
}
}
}
Additional vars per app:
api-reference: SPECS_R2_BASE_URL, CLOUDFLARE_ENVresources: REFERENCES_R2_BASE_URL, CLOUDFLARE_ENVCLOUDFLARE_ENVopen-next.config.ts (added to each app)import { defineCloudflareConfig } from "@opennextjs/cloudflare"
export default defineCloudflareConfig()
package.json changes (each app)Added build/deploy scripts:
{
"scripts": {
"build:cloudflare": "opennextjs-cloudflare build",
"deploy": "opennextjs-cloudflare deploy",
"preview": "opennextjs-cloudflare preview"
}
}
Added @opennextjs/cloudflare as a dev dependency.
Important: deploy script must NOT include yarn build:cloudflare && — the Cloudflare Worker CI build command runs the build, and the deploy command (wrangler deploy) uploads the already-built output. Running build again in deploy causes next build to run twice.
www/package.json — monorepo-level build/deploy scriptsAdded convenience scripts:
"build:cf:book": "cd apps/book && npx @opennextjs/cloudflare@latest build",
"deploy:cf:book": "cd apps/book && npx @opennextjs/cloudflare@latest build && wrangler deploy"
For each app, configured in the Cloudflare Worker "Builds" settings:
cd ../.. && yarn build:packages && cd apps/<appname> && yarn run build:cloudflarewrangler deploy (no rebuild — uses already-built .open-next/worker.js)www/apps/<appname>yarn build:packages compiles workspace packages (remark-rehype-plugins, docs-utils, etc.) before the app build. Without this, next.config.mjs fails to import them.
vercel.json from each appThe vercel.json files were deleted. They contained Vercel-specific build config and the Algolia cron for api-reference.
Deleted files:
www/apps/bloom/vercel.jsonwww/apps/book/vercel.jsonwww/apps/api-reference/vercel.json (also had cron for /api/algolia)www/apps/cloud/vercel.jsonwww/apps/resources/vercel.jsonwww/apps/ui/vercel.jsonwww/apps/user-guide/vercel.jsonwww/ignore-build-script.shThis script powered Vercel's ignoreCommand to skip builds when unrelated files changed. Not needed on Cloudflare (branch-based preview triggers handle this).
public/_headers to each appAdds CORS and cache headers for static assets:
/*
Access-Control-Allow-Origin: *
fs-based API RoutesCloudflare Workers run in V8 isolates — fs is unavailable at runtime. All routes that read files from disk were refactored.
workerCompatibleFetch utilityAdded www/packages/docs-utils/src/worker-compatible-fetch.ts:
export async function workerCompatibleFetch<T>({ url, responseTransformer, fallbackAction, useRemote }) {
const shouldFetch = useRemote || /^https?:\/\//.test(url)
if (shouldFetch) {
const res = await fetch(url)
return await responseTransformer(res)
}
return fallbackAction()
}
url starts with https:// (R2 URL) or useRemote is true: fetches via HTTPfallbackAction (local filesystem, for dev only)md-content/[[...slug]]/route.ts (all apps)The route serves raw MDX source. Changed to:
CLOUDFLARE_ENV to determine if running on CloudflareworkerCompatibleFetch with useRemote: !!process.env.CLOUDFLARE_ENVapi/references/[...slug]/route.ts (resources)Loads reference MDX from references/ directory:
NEXT_PUBLIC_REFERENCES_R2_BASE_URL (build-time) and REFERENCES_R2_BASE_URL (runtime)basePath: "/www/apps/resources" (fixed path, not process.cwd())app/schema/route.ts, app/download/[area]/route.ts, app/base-specs/route.ts (api-reference)Fetch OpenAPI spec files:
utils/get-path-for-env.ts — uses / joining when SPECS_R2_BASE_URL is set, path.join otherwiseSPECS_R2_BASE_URL runtime var → fetches specs from R2utils/dereference.ts (api-reference)@readme/openapi-parser uses Node.js https.get internally. Overrode its HTTP resolver with a custom fetchHttpResolver that uses the global fetch() API.
Three R2 buckets (or path prefixes within one bucket) were set up:
| App | Bucket/prefix | Env var | Content |
|---|---|---|---|
api-reference | docs-api-reference | SPECS_R2_BASE_URL | specs/ directory (OpenAPI YAML files, ~2300 files, 24MB) |
resources | docs-resources/references | REFERENCES_R2_BASE_URL | references/ directory (TypeDoc MDX output) |
ui | docs-ui/specs | UI_SPECS_R2_BASE_URL | specs/ directory (component specs) |
www/apps/api-reference/scripts/upload-specs-to-r2.mjswww/apps/resources/scripts/upload-references-to-r2.mjswww/apps/ui/scripts/upload-specs-to-r2.mjs.github/workflows/sync-api-reference-specs-to-r2.yml — syncs specs/ to R2 on push.github/workflows/sync-resources-references-to-r2.yml — syncs references/ to R2 on push.github/workflows/sync-ui-specs-to-r2.yml — syncs specs/ to R2 on push.github/workflows/algolia-api-indexer.yml — replaces the Vercel cron; runs Algolia indexing on a scheduleVERCEL_ENV → CLOUDFLARE_ENVAll occurrences of process.env.VERCEL_ENV === "production" and process.env.CF_PAGES === "1" were replaced with !!process.env.CLOUDFLARE_ENV. The CLOUDFLARE_ENV=1 var is set in each app's wrangler.jsonc vars block.
Files changed (~24 occurrences across):
www/apps/*/next.config.mjswww/apps/*/app/md-content/[[...slug]]/route.tswww/apps/*/scripts/prepare.mjswww/apps/resources/utils/fetch-mdx-content.tswww/apps/book/utils/fetch-raw-mdx.tswww/apps/book/utils/get-clean-md-cached.tswww/packages/docs-utils/src/worker-compatible-fetch.ts| Variable | Apps | Purpose |
|---|---|---|
NEXT_PUBLIC_REFERENCES_R2_BASE_URL | resources | R2 URL for fetching references (inlined by Next.js) |
NEXT_PUBLIC_* (all existing) | all | Inlined by Next.js next build; must be present at build time |
wrangler.jsonc vars or as Worker secrets)| Variable | Apps | Purpose |
|---|---|---|
CLOUDFLARE_ENV | all | Signals Cloudflare runtime; enables remote fetch paths |
SPECS_R2_BASE_URL | api-reference | Base URL for OpenAPI spec files in R2 |
REFERENCES_R2_BASE_URL | resources | Base URL for references directory in R2 |
UI_SPECS_R2_BASE_URL | ui | Base URL for UI component specs in R2 |
LOOPS_API_KEY | book | Email service (runtime, not build-time) |
The link-fixer plugins (typeListLinkFixerPlugin, workflowDiagramLinkFixerPlugin, prerequisitesLinkFixerPlugin, localLinksRehypePlugin) were updated to:
r2BaseUrl option — when set, fetches linked MDX files from R2 to read frontmatter slugsbasePath as a fixed string instead of relying on process.cwd()workerCompatibleFetch for remote slug resolutionfix-link.ts changesgetFileSlugSync failures are caught and fall through to path-based URL generationbasePath string rather than process.cwd()The /api/algolia/route.ts route was deleted. It used JSDOM (Node.js-only) to crawl rendered HTML for indexing, which cannot run in Cloudflare Workers.
Replacement: a standalone GitHub Actions workflow (.github/workflows/algolia-api-indexer.yml) runs the indexing script (www/apps/api-reference/scripts/index-algolia.mjs) on a cron schedule (Thursdays at midnight UTC), replacing the Vercel cron trigger.
A script (www/scripts/cf-set-preview-branch.sh) was added to restrict preview builds to docs/* branches via the Cloudflare Builds API, using env vars:
CLOUDFLARE_ACCOUNT_IDCLOUDFLARE_API_TOKENWORKER_NAMENEXT_PUBLIC_REFERENCES_R2_BASE_URL must be set as a build-time env var in Cloudflare Worker build settings (it's inlined by Next.js at build time).wrangler r2 object put.posthog-node produces build-time warnings about process.exit and CompressionStream in Edge Runtime — these are warnings only and do not affect runtime behavior.UI_SPECS_R2_BASE_URL is needed as both a build-time var (used in next.config.mjs) and a runtime var (used in the md-content route handler).