Real Ephemeris Math, Graceful AI Degradation, and a Rebuild Gotcha: Building an Astrology SaaS on Cloudflare Workers A developer built AstroMystra, a Next.js astrology SaaS that performs real ephemeris calculations for Vedic and Western charts and generates AI-written readings, and deployed it on Cloudflare Workers. The project uses the astronomy-engine library for planetary positions and a documented linear approximation for the Lahiri ayanamsa, and implements a model fallback system to handle Gemini API rate limits. The developer shared three engineering challenges: computing planetary positions without a C++ library, ensuring graceful degradation of LLM-dependent features, and migrating a Next.js app to Cloudflare Workers. Most "astrology app" tech stacks are a sun-sign lookup table and a template string. I've spent the last few months building AstroMystra https://astromystra.com , a Next.js app that does actual ephemeris calculations for Vedic sidereal and Western tropical charts, generates AI-written readings from those calculations, and — as of a few weeks ago — runs entirely on Cloudflare Workers instead of Vercel. Three engineering problems from that build were interesting enough to write up: how to get real planetary positions without a C++ ephemeris library, how to make an LLM-dependent product survive a 15-requests-per-minute free tier, and what actually breaks when you move a Next.js app to Cloudflare Workers. Vedic sidereal astrology and Western tropical astrology disagree about where the zodiac starts, by a slowly-growing offset called the ayanamsa — currently a little under 24°. Get this wrong and every single house and sign placement in a Vedic chart is wrong. The planetary positions themselves come from astronomy-engine https://github.com/cosinekitty/astronomy , a real numerical-integration ephemeris library this is the same one used by Astronomy.com , which gives tropical Earth-centered, equinox-referenced longitudes. To get sidereal positions for Vedic charts, you subtract the ayanamsa. The "correct" way to compute Lahiri ayanamsa involves modeling lunar/solar nutation — an 18.6-year wobble cycle. That's overkill for sign and nakshatra-level astrology nobody's natal Moon changes sign because of a 17-arcsecond wobble , so this is a documented linear approximation instead: / Lahiri Chitrapaksha ayanamsa: linear approximation anchored at J2000.0 23.85333° with a precession rate of 0.0139289°/year, derived from the published historical table 1900–2025 . True Lahiri ayanamsa has a small non-linear wobble from nutation ~18.6-year cycle, ~17 arcsecond amplitude that this linear model does not capture — acceptable for sign/nakshatra-level astrology, not for arcsecond-precision research use. / const J2000 AYANAMSA DEG = 23.85333; const RATE DEG PER YEAR = 0.0139289; export function lahiriAyanamsa date: Date : number { const yearsFromJ2000 = decimalYear date - 2000; return J2000 AYANAMSA DEG + yearsFromJ2000 RATE DEG PER YEAR; } The lesson here generalizes past astrology: know exactly which precision tier your domain actually needs, document the corner you're cutting and why, and don't drag in a heavier dependency or a slower calculation for accuracy nobody downstream can use. A comment that says "this is a linear approximation, here's the error bound, here's why it doesn't matter for this use case" is worth more than either silently doing it wrong or over-engineering it. Every reading — birth chart, compatibility, daily horoscope — is written by an LLM from the calculated chart data. The free-tier Gemini API this runs on is capped at 15 requests per minute, which is fine for a low-traffic app until it very suddenly isn't a cron job, a traffic spike, whatever . Two things handle this: Model fallback with a typed overload error. Instead of a generic try/catch, overload conditions get their own error class, and the call site tries a cheaper/lighter model before giving up: export class GeminiOverloadedError extends Error { constructor { super "Gemini API rate limit exceeded on all models" ; this.name = "GeminiOverloadedError"; } } async function geminiChat systemPrompt: string, userPrompt: string, lang = "en" { const MODELS = "gemini-2.5-flash", "gemini-2.5-flash-lite" ; for const modelName of MODELS { try { const model = gemini.getGenerativeModel { model: modelName, systemInstruction: systemPrompt } ; const result = await model.generateContent userPrompt ; return result.response.text ; } catch err { const status = err as any ?.status ?? err as any ?.response?.status; if status === 503 || status === 429 || status === 500 continue; // try next model throw err; // not an overload — don't swallow real bugs } } throw new GeminiOverloadedError ; } That GeminiOverloadedError bubbles up to the API route, which returns a 503 instead of a 500, and the frontend has a dedicated AiBusyBanner that shows a "high demand, try again shortly" state instead of a raw error. Distinguishing "the upstream is overloaded" from "our code is broken" as a type , not a string match on an error message, is what makes it possible to route these two failure modes completely differently in the UI without the routes and the banner component silently drifting out of sync over time. Pre-warming with a rate budget, not just a queue. A daily cron job generates all 12 zodiac sign horoscopes ahead of traffic. Naively firing 12 requests at once would blow straight through the 15 RPM ceiling. Instead: // Sequential with 4s gap — 12 signs × 4s = ~48s total, stays within 15 RPM free limit Nothing clever — just doing the arithmetic on the actual constraint 15/min → one request every 4s is safely under that with margin instead of reaching for a rate-limiting library for a problem that's really just "wait a bit between 12 sequential calls." There's also a second, paid model tier GPT-5 Mini via OpenRouter reserved for a premium plan, kept completely separate from the free-tier Gemini path — different failure domain, different cost profile, no reason to share a code path just because both "generate text." The migration itself, via @opennextjs/cloudflare , was mostly straightforward — next build differences aside, App Router deploys cleanly to Workers. Two things were genuinely worth knowing in advance: Hyperdrive wants the unpooled database connection. The Postgres instance sits behind Supabase's own connection pooler by default. Cloudflare's Hyperdrive does its own edge-side pooling, so pointing it at Supabase's pooled pgbouncer endpoint means stacking two poolers — which mostly works until it doesn't, under exactly the kind of connection-exhaustion conditions you'd rather not debug in production. The fix is boring: point Hyperdrive at the opennextjs-cloudflare deploy does not rebuild your app. This one cost a genuinely confusing 20 minutes. The deploy command uploads whatever's already sitting in .open-next/ — it does not invoke next build first. Ship a code change, forget to run cf:build immediately before cf:deploy , and you get a deploy that reports success while quietly serving five-day-old content. No error, no warning — the routes that exist in both builds serve fine, and only routes that are new since the last real build 404. If a Cloudflare deploy "succeeds" but a brand-new route 404s, check your .open-next/ build timestamp before you go looking for a routing bug that isn't there.None of these three problems are exotic — approximate a slowly-varying astronomical constant, degrade gracefully under a rate limit, don't trust a deploy tool's own "done" message — but they're the kind of thing that's easy to get subtly wrong in a way that looks fine until a specific edge case hits it in production. Writing the actual constraint down next to the code that handles it the ayanamsa comment, the typed error class, the RPM-budget comment turned out to matter more than any particular library choice. If you want to see where all this lands for an actual user: astromystra.com https://astromystra.com — Vedic and Western birth charts, AI-generated readings in English and Hindi, all built on the stack above.