From Vercel to Cloudflare: the $5 Plan and the OpenNext Tax
Why I moved this portfolio off Vercel and onto Cloudflare Workers. The bill went from $20 a month to $5, but OpenNext made me earn it: a middleware that would not build, no filesystem at runtime, and a handful of libraries I had to swap.
This site used to live on Vercel. It now runs on Cloudflare Workers, and the monthly bill dropped from twenty dollars to five. That headline makes the move sound obvious, and for my situation it was. But the four-times-cheaper number hides a real cost: the adapter that lets Next.js run on Workers, OpenNext, has sharp edges, and I hit most of them. This is the honest version of the story, the money, the architecture, and the week of debugging I paid for it.
The five-dollar question
The trigger was the renewal. Here is the comparison that actually applies to a solo developer running a real, public site rather than a throwaway demo.
| Plan | Price | What you get | The catch |
|---|---|---|---|
| Vercel Hobby | $0 | Generous for personal projects | No commercial use; hard usage caps |
| Vercel Pro | $20 / seat / month | Free usage allowances, then $20 credit, then metered | The realistic floor once a project is "real" |
| Cloudflare Workers (Free) | $0 | Real production tier, 100k requests/day | Smaller limits, 3 MiB worker size |
| Cloudflare Workers (Paid) | $5 / month | 10M requests, 30M CPU-ms, then cheap overage | You manage more of the stack yourself |
The numbers I care about: Vercel Pro is $20 per seat per month, and Cloudflare Workers Paid is a flat $5 per month. Cloudflare's $5 is not just for compute either. The same plan bundles Workers KV, D1, Hyperdrive, Durable Objects, and Pages Functions under one minimum charge, and Cloudflare does not meter egress bandwidth at all. To be fair, Vercel Pro bundles a large transfer allowance before egress is billed, so at a portfolio's scale that is rarely the line that bites. The structural difference is what I like: on Cloudflare, egress is simply never a meter I have to watch, which is one fewer thing to think about for a site that serves images and file downloads out of object storage.
A fair accounting of Vercel: the Hobby tier is free, and Pro bundles generous free usage allowances on top of the seat fee, with $20 of credit toward anything past them, so the seat fee is not pure overhead. The point is the floor. Once I decided I did not want to live inside Hobby's no-commercial-use terms, my realistic Vercel floor was Pro at $20, against $5 on Cloudflare. At my scale that is roughly a four-times monthly difference, and it does not climb with seats. That alone got me to try the move. What kept me up was everything after.
What I was actually moving
The reason this was not a one-afternoon migration is that the site is not a brochure. It is a Next.js 16 App Router app with:
- a Postgres database (Supabase) for domain data,
- better-auth for accounts and sessions,
- object storage for images and private file downloads,
- a few real-time multiplayer games,
- web push notifications,
- and an MDX blog (the one you are reading) with views, likes, and comments.
Every one of those touches a platform primitive, and platform primitives are exactly where Vercel and Cloudflare stop being interchangeable. Moving the static pages was trivial. Moving the stateful parts is where OpenNext earned its nickname in my head: the tax. (The secrets that feed all of this live in a self-hosted vault, which is its own story; the move changed where my code runs, not where its secrets live.)
OpenNext: how Next.js even runs on Workers
Cloudflare Workers do not run Next.js. They run a single JavaScript module against
a V8 isolate runtime that is close to, but not the same as, Node.js. Next.js
expects a Node server. Something has to bridge the two, and that something is
OpenNext, specifically the
@opennextjs/cloudflare adapter. It takes the Next.js build output and repackages
it into a Worker entrypoint, mapping Next's server features onto Cloudflare
bindings.
Once it builds, the runtime picture looks like this:
The mapping ended up being: Workers KV caches blog comments, D1 (Cloudflare's
SQLite) holds the auth tables, R2 stores files, and the app reaches Supabase
Postgres over its HTTP client rather than a long-lived database socket. None of
that wiring exists on Vercel, where you hand it a DATABASE_URL and move on. On
Workers you assemble it. When it works, it is genuinely elegant. Getting it to work
is the tax.
The middleware that would not build
This was the failure that nearly sent me back. Next.js 16 renamed middleware.
The middleware.ts convention became proxy.ts (a deliberate move to make the
network-boundary role explicit, running on the Node runtime; the old
middleware.ts is now deprecated). I dutifully renamed my file. The Cloudflare
build then died:
File server/middleware.js does not exist
The version of the OpenNext Cloudflare adapter I was on (1.16.1) had not caught up
to Next 16's new proxy convention. In my build it bundled the renamed proxy.ts
and then failed looking for server/middleware.js, the old name. This was a real
"version trap" between Next 16's proxy rename and the adapter, with no flag to
toggle my way out of it at the time. The adapter has been moving fast since: Next
16.2's stable Build Adapters API, built with the OpenNext team, was aimed squarely
at this class of breakage, so if you are starting today, check whether your
versions already handle proxy.ts before you rip anything out. I am documenting the
trap as it was, and why moving enforcement into the app was the right call
regardless.
What saved me is that my proxy was never doing anything that had to live at the edge. It was a coarse cookie check in front of a private area, redundant with the real enforcement. So I deleted it and pushed the enforcement down into the app itself: a server-component layout that redirects signed-out visitors, plus a guard wrapped around every protected route handler.
The result was actually stronger, not weaker: a cookie's mere presence at the edge proved nothing, whereas the layout and the per-route guard both check a real session. I verified the behavior after ripping the proxy out: a signed-out request to the private page returns a redirect to the login screen, and a signed-out request to its API returns a 401. The lesson generalizes. On the OpenNext path, do not treat middleware as a security boundary. Put real enforcement where the request is actually served.
The rest of the OpenNext tax
The middleware was the loudest problem, not the only one. Workers are not Node, and the gap shows up in specific, debuggable ways. Here is the full ledger of what bit me and what fixed it.
| What broke | Why | The fix |
|---|---|---|
fs.readFileSync threw at runtime | The Worker has no filesystem | Extract all blog metadata into a generated manifest at build time |
| Pages hung intermittently in prod | cacheComponents ("use cache") is not guaranteed on the Workers runtime (Next.js warns about its timer handling) | Leave that API off; prerender statically instead |
| Build rejected the bundle | Workers cap the gzipped worker at 3 MiB (Free) / 10 MiB (Paid) | Lazy-load the heavy client routes so they split out of the entry bundle |
| Dynamic pages stalled | Async server components that read headers or session block the response until they resolve | Wrap them in a <Suspense> boundary so the static shell can stream |
web-push did not run | It assumes Node crypto APIs | Swap to @block65/webcrypto-web-push |
| The AWS S3 SDK did not run | Too Node-bound for the isolate | Swap to aws4fetch for R2 |
| Real-time over Pusher plus Vercel KV | Tied to the old platform | Move real-time to Supabase |
Two of these deserve more than a table row.
The caching one cost me a real day. Next 16's cacheComponents looked perfect for a
content site, but on Workers it produced intermittent hangs that only showed under
load, the worst kind of bug. Next.js itself warns that it cannot guarantee Cache
Components on this runtime, pointing at how the Worker handles timers, and in
production that warning cashed out as roughly one hung request in five. I turned the
feature off and lean on static prerendering, and in my config the adapter's
incremental cache is left as a no-op rather than wired to a KV or R2 backing.
Slightly less magic, completely reliable.
The connection one is the trap I am proudest of dodging, because it passes every quick test before it bites you in production. If you talk to Postgres through a normal TCP driver, you cache the client for its lifetime the way you would in Node. On Workers that backfires: isolates sleep between requests and wake holding references to TCP connections that died while they slept, then hang trying to reuse them until the runtime gives up with:
The Workers runtime canceled this request because it detected that your Worker's code had hung and would never generate a response.
There are two ways out. Reach the database over HTTP, which is what I do through Supabase's client, so there is no socket to go stale in the first place. Or, if you need a real TCP driver, build a fresh client per request and let Cloudflare's Hyperdrive pool on the server side, pointed at Postgres's direct port rather than the pooled one, since Hyperdrive is itself the pool. I took the HTTP path and the whole class of bug never appeared.
And one operational note that is easy to trip over: building the Worker only works
cleanly on Linux. On Windows the bundler hits an ESM path bug, so I build and
deploy from CI. Logs come from wrangler tail rather than a dashboard panel, which
is a fair trade once you are used to it.
What the move actually bought me
After all that, would I undo it? No. Setting the price aside, the constraints made the app better:
- $5 instead of $20 at my scale, and egress that is never metered, which matters most for the file-heavy parts.
- One bill, one platform for the Worker, KV, D1, and R2, instead of stitching together separate services and invoices.
- A genuinely global edge by default.
- Cleaner architecture, forced. Build-time data instead of runtime filesystem reads, explicit Suspense boundaries, real auth checks instead of an edge cookie glance. Every workaround taught me something concrete about how the runtime behaves, which is exactly the kind of thing a portfolio project is for.
Would I do it again?
It depends entirely on who is asking. If you are a team shipping features under deadline, Vercel's near-zero-friction deploys and first-party Next.js support are very likely worth $20 a month. You are paying for the rough edges to already be sanded down, and they are.
If you are a solo developer who has the time to pay a one-time tax and wants to understand the machine underneath, Cloudflare at $5 is a clear win. The important word is one-time. The OpenNext tax is front-loaded. You pay it in a bad week of hung requests and failed builds, and then the thing just runs, cheaply, on infrastructure you finally understand. For this site, that was an easy trade. The next time I deploy, I do it for five dollars and I know exactly why every binding is where it is.
Specifics, for the curious: OpenNext for Cloudflare, the Cloudflare Workers pricing and limits, and Next.js on renaming Middleware to Proxy.
Comments
Loading comments…