What breaks when you ship Next.js on Cloudflare Workers · Finterm Blog<br>← Back to blogMay 16, 2026· cloudflare · nextjs · javascript<br>What breaks when you ship Next.js on Cloudflare Workers<br>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. Every package in your dependency tree has to work in the Workers runtime, and a surprising number of common ones don't.<br>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.<br>bcrypt → Argon2id via @noble/hashes<br>bcryptjs reaches for crypto.randomBytes and a few other Node primitives that aren't polyfilled in Workers. Argon2id is the modern alternative anyway, and @noble/hashes exposes a pure-JS implementation that runs anywhere and lines up with current OWASP guidance. The migration was a one-line change in the password hasher plus a re-hash on next login.<br>cheerio / jsdom / parse5 → htmlparser2<br>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.<br>The Yahoo Finance crumb handshake<br>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.<br>const seed = await fetch('https://fc.yahoo.com/', { headers: BROWSERY });<br>const cookie = seed.headers.getSetCookie()<br>.map(c => c.split(';')[0])<br>.filter(c => c.startsWith('A1=') || c.startsWith('A3=') || c.startsWith('A1S='))<br>.join('; ');<br>const crumbRes = await fetch('https://query1.finance.yahoo.com/v1/test/getcrumb', {<br>headers: { ...BROWSERY, Cookie: cookie },<br>});<br>const crumb = (await crumbRes.text()).trim();<br>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.<br>Single outbound gateway<br>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.<br>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.<br>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.<br>Pop-outs that share React state<br>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.<br>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.<br>Result<br>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.<br>Happy to go deeper on any of the above if there's interest.