← Journal
Build log / 05 · 6 min read

Moving the site from Vercel to Cloudflare Pages

The site is static except for one endpoint: the contact form posts to a small serverless function that files the message into DodoForm. Moving hosts meant porting that function, and it is where all the interesting differences turned up.

The original handler was written in the Vercel style: a default export taking a request and a response object, with helpers like res.status(400).json(...). It read environment variables from process.env and imported randomUUID from node:crypto.

Cloudflare Pages Functions are a different shape. You export onRequestPost and receive a single context object. There is no response helper to mutate — you return a standard Response. Environment variables arrive on context.env rather than process.env. And because it runs on the Workers runtime rather than Node, node:crypto is not the way to get a UUID; crypto.randomUUID is simply available as a global.

None of that is difficult, but it is enough that copying the file across would have produced something that failed at runtime rather than at build time. We deleted the Vercel version rather than keeping both, because two copies of the same logic diverge the moment you fix a bug in one of them.

The risk with any serverless function is that the thing you test locally is not the thing that runs in production. We had solved that once already for Vercel with a small dev middleware, and we wanted the same guarantee afterwards.

The fix is that the dev server imports and calls the exact Pages Function. A tiny shim builds a standard Request from the incoming Node request, hands it to the same onRequestPost, then copies the returned Response back out. One handler, used in both places. When we later tested validation, the honeypot and a real submission locally, we were exercising the code that would actually be deployed.

We expected to write a redirect rule so that client-side routes did not 404. It turned out Pages already does this: if your build output has no top-level 404.html, it treats the project as a single-page app and serves index.html for unmatched paths.

So the correct action was to add nothing. Worth checking before you paste in a catch-all rewrite, which is the kind of configuration that quietly breaks something else later.

Immediately after the first deploy, every request to the new pages.dev hostname returned 522. That reads like a broken deployment. It was propagation, and it cleared on its own within a minute or two.

Then the custom domain sat at pending. The domain had been attached to the project over the API, but a Pages custom domain needs two things: the domain registered on the project and a DNS record pointing at it. Adding the domain through the dashboard does both; doing it over the API had only done the first. Until the CNAME existed, validation could not complete, so the status stayed pending indefinitely — not failing, just waiting for something that was never going to arrive.

Once the record was in place it went active on its own, certificate included.

The API key the function needs is set as a project secret, not committed. The repository has an example env file listing the names of the variables with no values, and the real file is ignored by git.

One detail worth stating because it is easy to get wrong with Vite: anything named with a VITE_ prefix is bundled into the public JavaScript. A secret key must never carry that prefix. We verified this the boring way after wiring it up — built the site with a fake key in place and searched the output bundle to confirm the secret was absent and only the intentionally-public value appeared.

Follow on X →