What breaks when you ship Next.js on Cloudflare Workers
A log of which npm packages don't survive the Workers runtime, what to swap them with, and the patterns that ended up mattering.
I've been building Finterm, a financial terminal that runs entirely in a browser tab, on Cloudflare Workers via @opennextjs/cloudflare. The promise is the obvious one — global edge runtime, near-zero cold starts, pay-per-request pricing. The catch is that Workers don't run Node.js by default. Every package in your dependency tree either has to work in the Workers runtime natively or be reachable through the nodejs_compat compatibility flag, and a few common ones still don't fit either path.
This is a rough log of what I had to rip out and what I replaced it with, plus a couple of patterns that turned out to be necessary on Workers but irrelevant on Node.
bcrypt → Argon2id via @noble/hashes
bcryptjs runs on Workers if you enable the nodejs_compat flag, which exposes crypto.randomBytes and most of node:crypto. I went with Argon2id instead for unrelated reasons: OWASP currently recommends Argon2id over bcrypt for new password storage, and @noble/hashes gives you a memory-hard implementation with zero Node-compat surface area at all, which felt cleaner for an edge runtime. The migration was a one-line change in the password hasher plus a re-hash on next login.
cheerio / jsdom / parse5 → htmlparser2
For the news reader I needed to extract article bodies from arbitrary HTML. cheerio pulls in parse5, which doesn't run on Workers, and jsdom is even further away from compatibility. htmlparser2 is SAX-style streaming and pure JS, so it runs anywhere — but you have to walk the events yourself rather than querying a tree. Worth it for the runtime portability.
The Yahoo Finance crumb handshake
Not a Workers issue specifically, but I hit it harder because of Workers. Yahoo's quoteSummary, options, and profile endpoints require a session cookie and a crumb token bound to that session. The dance: first you GET https://fc.yahoo.com/, which returns 404 with Set-Cookie for A1 and A3; then you GET /v1/test/getcrumb with those cookies, which returns the crumb as plain text; then every gated request needs that cookie plus &crumb=... appended to the URL.
A few gotchas. The getcrumb endpoint returns text/plain, not JSON, so you can't send an Accept: application/json header — it'll 406. The User-Agent has to look browsery; a plain curl/8.x or your fetch library's default gets rate-limited fast. The cookie and crumb stay valid for roughly thirty minutes, after which you re-handshake. I cache the pair per-isolate and refresh on the first 401.
Single outbound gateway
Workers isolates are short-lived but they do persist module state across requests on a single isolate, which makes the obvious caching patterns work better than on a per-request serverless model.
Every upstream HTTP call in the app goes through one route — /api/data/[...path]. The route looks up the endpoint by path, runs the provider function, attaches Cache-Control: s-maxage=N, stale-while-revalidate=86400, and lets the Cloudflare edge cache do the heavy lifting. A four-tier per-IP rate limiter only fires on cache misses, so a popular ticker can be served to thousands of users without ever hitting Yahoo or SEC again that minute.
This pattern also gave me a single chokepoint for an SSRF guard, so user-supplied paths can't reach back into Cloudflare's metadata service or some internal hostname.
Pop-outs that share React state
Off the Workers topic but a fun one. Each window in the dashboard has a popout button. It calls window.open('/popout.html'), then React-renders the same component switch into the popout's document. The popout writes back to the parent's workspace state through a callback, so edits round-trip in real time.
Two non-obvious things had to change in the window-body components. Any addEventListener or matchMedia has to be bound to the popout's own window (I expose this through a useOwnerWindow(ref) hook). And any document.createElement for an offscreen canvas has to go through someExistingNode.ownerDocument.createElement so the node belongs to the popout's DOM rather than the parent's.
Result
Finterm is live at finterm.xyz — open it, type a ticker, and a chart, SEC fundamentals, options chain with Greeks, and analyst estimates open as draggable windows. Everything described above runs in Cloudflare's edge: no Node server, no cold starts to speak of.
Happy to go deeper on any of the above if there's interest.