Every aurora app answers the wrong question. They show you Kp indexes, solar wind speed, magnetometer plots — great if you're a space-weather nerd. But a traveler standing in Tromsø asks something simpler: "will I actually see it tonight?"
The honest answer depends as much on clouds, darkness, and the moon as it does on the sun. A Kp 7 storm is worthless under overcast skies. A quiet Kp 2 night in Abisko in December can be magical.
So I built Aurora Visible — one score per destination per night, computed from the four things that actually decide whether you'll see anything:
score = 10 × darkness × geomagnetic × clear-sky × moonlight
Eight destinations (Tromsø, Abisko, Reykjavík, Fairbanks, Yellowknife…), a verdict for tonight updated every 30 minutes from NOAA and Open-Meteo data, monthly odds computed from 30 years of geomagnetic history, and month-by-month pages for trip planning. In 17 languages, because aurora tourists come from everywhere.
This post is about what it took to run that on Cloudflare Workers — because I hit, in one day, pretty much every limit the platform has.
The stack
- Next.js 16 (App Router) via @opennextjs/cloudflare on Workers
- R2 for the ISR page cache, a Durable Object queue for timed revalidation
- 2,524 statically prerendered routes (8 destinations × 12 months × 17 locales, plus compare pages, FAQ, guides)
- suncalc for darkness windows, NOAA SWPC + GFZ for geomagnetic data, Open-Meteo for clouds
- Plausible (cookieless) for analytics, Sentry for errors, Playwright + Vitest for tests
The appeal of Workers: the free tier allows commercial use, and the paid tier is $5/month. The catch: it is not a Node server, and the limits are real.
War story #1: one innocent headers() call nuked my entire ISR setup
I wanted the 404 page localized. not-found.tsx boundaries don't receive route params in Next.js, so I had my middleware forward the locale in an x-locale header and read it with headers() in the boundary. Clean, right?
What I didn't realize: a dynamic API call anywhere in a layout segment opts every non-force-static page in that segment out of static generation. My home page, storm tracker, and destination pages — all revalidate: 1800 ISR — silently became fully dynamic. Every request did a complete React server render with ~10 upstream data fetches.
On Cloudflare's free tier, a Worker invocation gets 50 subrequests — and with OpenNext, every R2 read, every Cache API call, every Durable Object ping counts. Cache-miss renders blew past 50 and the site started throwing Too many subrequests by single Worker invocation.
The fix: make the 404 a client component that derives the locale from usePathname(), with a tiny standalone map of just the 404 strings (importing the full message catalogs would have shipped ~460 KB of translations to every visitor). A unit test keeps the map in sync with the catalogs.
Diagnostic that saved me: check .next/prerender-manifest.json after builds. The route count dropping from 2,490 to 2,303 is the tell — I now assert on it.
War story #2: the 10 ms CPU ceiling
Errors kept coming after that fix. wrangler tail --format json gave me the receipts:
outcome: exceededCpu cpu: 10ms /az/storm
outcome: ok cpu: 19ms /de/kiruna ← survived on burst tolerance
outcome: ok cpu: 991ms /en/tromso ← a full ISR regeneration
The free tier allows 10 ms of CPU per invocation. A Next.js page render through the OpenNext server path costs 15–20 ms even when it's mostly serving from cache, and an ISR regeneration costs 50–1000 ms. Whether a given request survived was literally luck — Cloudflare's enforcement allows bursts, so identical requests would alternate between 200 and 503.
There is no code fix for this. React SSR does not fit in 10 ms. The $5/month paid plan raises the ceiling to 30 seconds and the errors stopped within a minute of upgrading. If you're putting any SSR framework on Workers: budget the $5 from day one and save yourself the afternoon.
War story #3: subrequest arithmetic
Even on the paid plan, subrequests are worth respecting. My forecast engine fetched Open-Meteo once per destination — 8 fetches, each with cache reads/writes around it. Open-Meteo accepts comma-separated coordinate lists:
/v1/forecast?latitude=69.6,68.4,64.1,…&longitude=18.9,18.8,-21.9,…
One request, results in order, one cache entry for the whole site. A page regeneration went from ~40 subrequests to ~12.
Smaller lessons
- suncalc 2.x returns degrees, not radians. Changed from 1.x. I lost a real bug to "fixing" the missing conversion.
- Pin your Next.js version. Next 16.4 emits a build manifest file the Cloudflare adapter doesn't whitelist yet; the worker 500s on boot. I'm pinned to 16.3.8 until the adapter catches up.
-
Deterministic e2e beats mocking. All upstream feeds go through one fetch module with a fixtures mode (
SOURCE_FIXTURES=1) and a pinned clock (FIXED_NOW=…). 53 Playwright tests run against a real production build with zero network and zero flake. -
wrangler tail --format jsonis the single most useful debugging tool on Workers. Dashboard error counts tell you nothing; the tail gives you per-invocation CPU time, outcomes, and exceptions.
Was Workers the right call?
For this site — yes, now that I understand the shape of it. 2,524 prerendered pages served from R2 behind Cloudflare's edge, background revalidation every 30 minutes, global latency that makes Lighthouse happy, for $5/month at any realistic traffic level. The same thing on Vercel would be $20/month before I could legally put an affiliate link on it (Hobby tier forbids commercial use).
But the mental model is different from a Node server, and the free tier is for APIs and tiny pages, not SSR frameworks. Go in knowing that.
The site is live at auroravisible.com — aurora season just started and we're near solar maximum, so if you've ever wanted to see the northern lights, the next few months are the best window in a decade. I'd genuinely love feedback, especially from anyone who's run OpenNext on Workers at larger scale.
Happy to answer anything about the scoring model, the i18n setup (17 locales on one [locale] tree), or the Cloudflare specifics in the comments.
Top comments (0)