Finterm.Search commands, files, windows…⌘KDocs
LIVE · 41ms
UTC
← Back to blog
May 16, 2026· cloudflare · nextjs · javascript

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. Every package in your dependency tree has to work in the Workers runtime, and a surprising number of common ones don't.

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 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.

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.

const seed = await fetch('https://fc.yahoo.com/', { headers: BROWSERY });
const cookie = seed.headers.getSetCookie()
  .map(c => c.split(';')[0])
  .filter(c => c.startsWith('A1=') || c.startsWith('A3=') || c.startsWith('A1S='))
  .join('; ');
const crumbRes = await fetch('https://query1.finance.yahoo.com/v1/test/getcrumb', {
  headers: { ...BROWSERY, Cookie: cookie },
});
const crumb = (await crumbRes.text()).trim();

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.