Writing · June 30, 2026 · 11 min read
Leaving Vercel: migrating a production Next.js side project to Cloudflare
Next.js · Cloudflare · Vercel · migration
TL;DR: My job board side project's content site lived happily on Vercel's free tier until monetization plans collided with the Hobby plan's no-commercial-use terms. I weighed three ways out: pay for Vercel Pro, hide Vercel behind Cloudflare's proxy, or migrate outright. I migrated, to Cloudflare Pages. The build was the easy half, once I ignored the official recommendation: the OpenNext adapter built cleanly and then 404'd every prerendered page, while plain static export just worked and costs nothing to serve. The hard half was everything around the build. A DNSSEC setting deadlocked the DNS transfer for more than fifteen hours. A test URL nearly got my real domain flagged for duplicate content. A www redirect deployed without error and silently did nothing. Cloudflare's defaults blocked AI crawlers on a site that actively wants AI citations. And one missing build-time environment variable cost me about a month of analytics attribution.
The problem, concretely
It was not performance. For static content, Vercel's edge and Cloudflare's edge are indistinguishable to a visitor, and if speed had been my problem I would have saved myself the whole migration. The forcing function was legal, and the timer was financial.
Vercel's Hobby plan prohibits commercial use. The moment a side project takes payments, runs ads, or sells listings, staying on the free tier is a terms-of-service violation, not a clever hack, and my project was heading exactly there. Behind the terms issue sat a capacity ceiling: the Hobby tier's 100 GB of monthly bandwidth runs out somewhere around 30 to 50 thousand unique visitors for a content-heavy site, a number I intended to reach. So: a site that could not legally monetize where it stood, on a tier it would outgrow anyway.
The options on the table
Option one: pay for Vercel Pro. Twenty dollars a month per project makes the terms problem vanish overnight, with zero engineering work and all the deploy ergonomics I already liked. What pushed me away:
- the price is per project, and my setup spans several deployables, so the real bill multiplies,
- it is a fixed cost that starts before the first revenue does,
- it buys server capabilities that a fully prerendered site never uses.
Option two: keep Vercel, put Cloudflare in front. Pointing Cloudflare's DNS proxy at a Vercel origin gets you edge caching and DDoS protection for free while keeping Vercel's ergonomics, and it is an afternoon of setup rather than a migration. For many projects it is genuinely good enough. But:
- it does nothing about the terms of service, the site is still hosted on Vercel, so it quietly collapses into option one plus an extra moving part,
- the extra layer brings its own friction: cache staleness on deploys and an origin that no longer sees real visitor IPs.
Option three: migrate fully to Cloudflare Pages. Cloudflare's free tier serves static files with no invocation metering at all, which for a prerendered site means the bill stays at zero indefinitely, and the terms problem disappears with the hosting. The costs are all upfront:
- real migration work, days for a static site and one to two weeks for an app with a database and auth,
- giving up Next.js server conveniences that only exist on a server,
- moving the domain's DNS to Cloudflare, which is the riskiest step of the whole affair.
What I chose, and exactly why
Full migration, but only for the content site. The reasoning: the terms problem was the hard constraint, and option two does not address it at all, so the real choice was between paying Pro forever and migrating once. The content site was already 100 percent statically generated, which put it in the best possible position to migrate cheaply and to cost exactly zero to serve afterwards. Paying a multiplying monthly fee for server features a static site cannot use lost that argument. The dynamic half of the project, the job board itself with auth, an admin panel, and live database queries, I deliberately left for later: its raw TCP database client does not run on Cloudflare's Workers at all, so that migration is a refactor, not a redeploy. If Pro pricing had been acceptable to me, option two would have been my answer, and it is the one I would hand anyone not fighting the terms or the ceiling.
Three ways to run Next.js on Cloudflare, one of which worked
Cloudflare's docs currently steer Next.js users toward the OpenNext adapter on Workers, which supports the full server feature set: ISR, Server Actions, dynamic routes. The legacy adapter is capped at older Next versions and effectively dead. And then there is the boring option: Next's built-in static export, a folder of plain HTML that Pages serves directly with zero Worker invocations.
I tried OpenNext first, because it was the official recommendation. It required bumping Next.js to a minimum version, scaffolded its config through a migration command, and built without a single warning. Then every prerendered content page returned 404 in production. The failure is nasty precisely because it is silent: for a fully statically generated codebase, the adapter did not copy the prerendered HTML into the served assets, so the Worker tried to render pages at request time, hit filesystem reads that only exist at build time, and gave up. I lost two to three hours confirming it was not my bug before reverting.
The lesson generalizes: pick the adapter by the shape of your codebase, not by what the platform docs recommend this quarter. My decision tree ended up being one question:
Is every route force-static, with no ISR and no Server Actions touching user data? Then use static export and skip the adapter entirely. Only reach for OpenNext when you genuinely need a server, and budget real debugging days if you do.
For the content site the answer was yes on every count, so static export won. When the job board's turn comes, the answer will be no, which means the OpenNext path with all its corner cases plus swapping the database client for an HTTP-based data layer or a connection proxy. Different animal, different budget.
What static export takes away from you
Flipping the config to export mode is one line. Paying for it is a small tour of everything Next.js was quietly doing on the server for you.
- Headers and redirects from the Next config stop existing. With no runtime to apply them, Pages replaces them with two plain-text files deployed alongside the site: one for response headers (security headers, immutable caching for hashed assets), one for path redirects. Most rules translate mechanically; one important one does not, and it comes up later.
- Runtime image optimization is gone. Export mode requires marking images unoptimized. The options: preoptimize at build time, serve plain
imgtags, or pay for the platform's image service. My images were already sized and compressed in the repo, so unoptimized was fine. - Every metadata route must opt into static. Recent Next versions refuse to export the manifest, robots file, and icon routes unless each one explicitly declares
force-static. The build errors name the offenders one at a time, so this is tedious rather than hard. - Anything request-shaped is off the table. No middleware, no ISR, no Server Actions. If you were leaning on any of them, you are back in adapter territory.
Before touching config I audited the codebase with a handful of greps: dynamic route declarations, Node-only APIs in runtime code, Vercel-specific dependencies and environment reads. One trap worth passing on: I filtered the greps to TypeScript files, concluded a UI library was unused, removed it, and broke the build, because its one consumer was a legacy JavaScript file the filter skipped. Audit the whole tree, then filter the findings.
Also: no dedicated migration branch. The same main branch deployed identically to both platforms, behind a tiny helper answering "is this a production deploy?" from whichever platform's environment variables are present. Platform-specific branches always drift.
The duplicate content trap
Every Pages project gets a permanent test URL on the platform's shared domain, and during the side-by-side phase that URL serves content identical to your real domain. If Google indexes both, your actual domain eats a duplicate content penalty. I defended in layers: a robots file disallowing everything and layout metadata setting noindex, both keyed to non-production builds.
Two real bugs hid inside that plan. First, six pages hardcoded an indexable robots value in their own metadata, and per-page metadata overrides the layout, so those six were cheerfully indexable on the test URL while the layout looked correct. Grep for per-page robots overrides early; make every one conditional. Second, the CI workflow initially set the production-branch marker variable on every build, which made the production check return true for builds going to the test URL, flipping its robots file to allow-all. The fix: leave that variable unset, with a loud TODO, and enable it in the same change as the DNS swap.
I ran both platforms in parallel for about two weeks, diffing page checksums daily and comparing Lighthouse runs, and did not switch DNS until a newly published post had shown up identically on both.
DNS: the part that actually hurts
Cloudflare Pages refuses to attach a custom domain unless Cloudflare manages the domain's DNS, so the migration necessarily includes moving the zone: import the existing records, verify the mail-related ones survived, then change nameservers at the registrar. Two things went memorably wrong.
DNSSEC deadlocked the transfer. My domain had DNSSEC enabled at the old registrar. Submitting a nameserver change while DNSSEC is on puts the registry in an impossible position: the existing DS records reference the old keys, and the new zone cannot present valid ones until it is active, which it cannot become until the nameserver change lands. The registrar UI showed "activating" for over fifteen hours with no error; the TLD's WHOIS showed a blank nameserver field, the tell that the registry rejected the update rather than processed it slowly. The escape was deactivating DNSSEC mid-flight, which itself takes about a day. The prevention is one DS-record query before you start: empty output means safe to proceed, anything else means disable DNSSEC first, wait the full day, then change nameservers, and re-enable it afterwards through the new provider.
Sequencing decides your downtime. The tempting order is to remove the domain from Vercel first, tidily, then attach it on Cloudflare. I did that, and for the window while DNS still resolved to Vercel's IP, Vercel received requests for a domain no project claimed and returned a deployment-not-found 404. Production was down. The correct order keeps the old claim alive until the new platform holds the domain: wait for nameserver propagation, attach the custom domain (which swaps the A record to the new edge within seconds), confirm traffic flows, and only then remove the domain from the old platform as cleanup. Done that way, with DNS TTL lowered to 300 seconds a day in advance, the observable gap was roughly thirty seconds of certificate provisioning instead of half an hour of 404s. And if you fumble it, re-adding the domain on the old platform restores service immediately; write that rollback down before you begin.
Three surprises after the switch
The www redirect that deployed and did nothing. I put a hostname-to-hostname rule, www to apex, into the path-redirects file. It deployed clean, and www traffic kept returning 200. The redirects file only accepts paths as sources, never full URLs with hostnames, and unsupported lines are silently ignored rather than rejected. Domain-level redirects belong in a zone-level redirect rule, where the platform even ships a www-to-root template. Two sub-gotchas: the template does not preserve query strings by default, which quietly breaks campaign attribution, and the edge cache can keep serving cached 200s after the rule deploys, so test with a cache-busting query parameter before concluding the rule is broken. I lost twenty minutes to the first and nearly misdiagnosed the second.
The platform blocked AI crawlers without asking. Onboarding a zone can silently enable two separate features: an edge rule that rejects verified AI bots, and a managed robots file that injects disallow rules for AI crawlers above whatever your app serves. For a scraping-target site those defaults are reasonable. For a content site whose strategy includes being cited by AI assistants, they are exactly backwards: my own robots rules said allow, and the injected block overrode them invisibly. Curl your robots file after migrating; if a managed section appears above your rules, decide deliberately whether both toggles should be off for your kind of site.
The month of missing analytics. Public client-side environment variables in Next.js are inlined at build time. Vercel had injected my analytics key into builds automatically for so long that I forgot it was configuration at all. The new CI build step did not carry the variable, and, crucially, setting it in the platform's dashboard does nothing for a static export, because dashboard variables exist at runtime and a static export has no runtime. The bundle shipped an undefined key, the analytics client no-opped, and everything looked fine: green pipeline, working site. Search Console showed clicks while analytics showed zeros for about a month before I connected the dots. Build-time variables belong in the CI build step's environment, sourced from repo secrets, with a build-time assertion that fails loudly when one is missing; the verification is a real browser confirming the analytics request returns 200, never a green pipeline.
What I would tell someone considering this
- Migrate for terms or scale, not vibes. If the paid tier is affordable, Cloudflare-in-front-of-Vercel captures most of the benefit for none of the risk.
- Match the adapter to the codebase, not the docs. Fully static site: use static export, skip OpenNext, save hours. Genuinely dynamic app: OpenNext, but test every route class on the preview URL and budget days, not an afternoon.
- Check DNSSEC before touching nameservers. One DS-record query costs ten seconds; skipping it cost me fifteen stuck hours plus a day of forced waiting.
- Never release the old platform's domain claim until the new one holds the domain. Sequencing is the difference between thirty seconds of downtime and a real outage, and re-adding the domain is your rollback.
- Treat the test URL as an SEO hazard. Noindex it in layers, hunt down per-page robots overrides, and keep the production marker out of CI until DNS actually points at the new platform.
- Audit platform defaults after the zone moves. AI-bot blocking and managed robots injection turn on silently and override your app's intent.
- Verify with requests, not green checkmarks. The silent failures were the expensive ones: an adapter that built clean and 404'd, a redirect line that no-opped, an undefined analytics key behind a passing pipeline. Every one was visible in thirty seconds of curl or a browser network tab.