Migrating Astro from Vercel to Cloudflare Pages
Moving a static Astro site from Vercel to Cloudflare Pages is mostly straightforward, but there are a few sharp edges worth documenting.
Cloudflare Pages is a compelling alternative to Vercel for static sites—especially if you’re already using Cloudflare for DNS and want to consolidate. Here’s what I learned migrating an Astro site.
The adapter
First, swap the Vercel adapter for Cloudflare’s:
pnpm remove @astrojs/vercel
pnpm add @astrojs/cloudflare wrangler
Configure astro.config.mjs
:
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
adapter: cloudflare({
imageService: "passthrough",
}),
});
Proxying external APIs
Vercel’s vercel.json
supports rewrites for proxying external services. In my
case, I was proxying Plausible Analytics to avoid ad blockers:
{
"rewrites": [
{ "source": "/api/event", "destination": "https://plausible.io/api/event" },
{ "source": "/js/script.js", "destination": "https://plausible.io/js/script.js" }
]
}
Cloudflare doesn’t have an equivalent. You need server endpoints.
Create src/pages/api/event.ts
:
export const prerender = false;
export async function POST({ request }: { request: Request }) {
const url = new URL(request.url);
const plausibleUrl = new URL("https://plausible.io/api/event");
url.searchParams.forEach((value, key) => {
plausibleUrl.searchParams.set(key, value);
});
const response = await fetch(plausibleUrl.toString(), {
method: "POST",
headers: {
"Content-Type": request.headers.get("Content-Type") || "application/json",
"User-Agent": request.headers.get("User-Agent") || "",
"X-Forwarded-For": request.headers.get("X-Forwarded-For") ||
request.headers.get("CF-Connecting-IP") || "",
},
body: request.body, // Stream, don't buffer
});
return new Response(response.body, {
status: response.status,
headers: {
"Content-Type": response.headers.get("Content-Type") || "application/json",
},
});
}
The critical detail: export const prerender = false;
prevents Astro from trying
to prerender the endpoint at build time, which fails when it tries to fetch from
an external service.
Also note body: request.body
streams the request through without buffering into
memory. Same for response.body
in the return value.
Headers and redirects
Vercel’s vercel.json
handles headers and redirects. Cloudflare Pages uses
platform-native files instead.
Create public/_headers
:
/*
Content-Security-Policy: base-uri 'none'; child-src 'self'; ...
Permissions-Policy: camera=(), microphone=(), geolocation=()
Referrer-Policy: no-referrer-when-downgrade
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Create public/_redirects
:
/old-path /new-path 301
These files get copied to dist/
during build and Cloudflare respects them.
The node:crypto problem
When testing locally with pnpm wrangler pages dev dist
, you might hit:
Error: No such module "node:crypto"
Cloudflare Workers don’t support Node.js built-ins by default. Enable them in
wrangler.jsonc
:
{
"compatibility_flags": ["nodejs_compat"]
}
Image optimization
The Cloudflare adapter supports imageService: "cloudflare"
for runtime image
optimization via Cloudflare Images. But if your pages are all prerendered (the
usual case for static sites), images are already optimized at build time by Sharp.
Runtime optimization only matters for server-rendered pages that need dynamic
image sizing. Check your build output—if you see ▶
symbols (not λ
), those
pages are prerendered and don’t need the runtime service.
Use imageService: "passthrough"
unless you specifically need runtime transforms.
Local testing
pnpm build
pnpm wrangler pages dev dist
This runs your site with Cloudflare’s local runtime. The _headers
, _redirects
,
and Pages Functions all work exactly like production.
Deploy
Connect your repo to Cloudflare Pages via the dashboard. Set:
- Build command:
pnpm build
- Output directory:
dist
The wrangler.jsonc
configuration gets picked up automatically.