<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Amritanshu Gaurav</title>
    <description>The latest articles on DEV Community by Amritanshu Gaurav (@amritanshu_gaurav_8c70b9e).</description>
    <link>https://dev.to/amritanshu_gaurav_8c70b9e</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081769%2F7826b69c-d6ac-4f7a-9059-32e7f17fef55.png</url>
      <title>DEV Community: Amritanshu Gaurav</title>
      <link>https://dev.to/amritanshu_gaurav_8c70b9e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amritanshu_gaurav_8c70b9e"/>
    <language>en</language>
    <item>
      <title>Real Ephemeris Math, Graceful AI Degradation, and a Rebuild Gotcha: Building an Astrology SaaS on Cloudflare Workers</title>
      <dc:creator>Amritanshu Gaurav</dc:creator>
      <pubDate>Mon, 17 Aug 2026 17:00:27 +0000</pubDate>
      <link>https://dev.to/amritanshu_gaurav_8c70b9e/real-ephemeris-math-graceful-ai-degradation-and-a-rebuild-gotcha-building-an-astrology-saas-on-308i</link>
      <guid>https://dev.to/amritanshu_gaurav_8c70b9e/real-ephemeris-math-graceful-ai-degradation-and-a-rebuild-gotcha-building-an-astrology-saas-on-308i</guid>
      <description>&lt;p&gt;Most "astrology app" tech stacks are a sun-sign lookup table and a template string. I've spent the last few months building &lt;a href="https://astromystra.com" rel="noopener noreferrer"&gt;AstroMystra&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Real ephemeris math, not a sun-sign table
&lt;/h2&gt;

&lt;p&gt;Vedic (sidereal) astrology and Western (tropical) astrology disagree about where the zodiac starts, by a slowly-growing offset called the &lt;strong&gt;ayanamsa&lt;/strong&gt; — currently a little under 24°. Get this wrong and every single house and sign placement in a Vedic chart is wrong.&lt;/p&gt;

&lt;p&gt;The planetary positions themselves come from &lt;a href="https://github.com/cosinekitty/astronomy" rel="noopener noreferrer"&gt;&lt;code&gt;astronomy-engine&lt;/code&gt;&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * 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.
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;J2000_AYANAMSA_DEG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;23.85333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATE_DEG_PER_YEAR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0139289&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;lahiriAyanamsa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yearsFromJ2000&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decimalYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;J2000_AYANAMSA_DEG&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;yearsFromJ2000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;RATE_DEG_PER_YEAR&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Surviving a 15 RPM free tier without users noticing
&lt;/h2&gt;

&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;Two things handle this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model fallback with a typed overload error.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GeminiOverloadedError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Gemini API rate limit exceeded on all models&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GeminiOverloadedError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;geminiChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MODELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gemini-2.5-flash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gemini-2.5-flash-lite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modelName&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;MODELS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gemini&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getGenerativeModel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;modelName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;systemInstruction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userPrompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// try next model&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// not an overload — don't swallow real bugs&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GeminiOverloadedError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;GeminiOverloadedError&lt;/code&gt; bubbles up to the API route, which returns a 503 instead of a 500, and the frontend has a dedicated &lt;code&gt;AiBusyBanner&lt;/code&gt; 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 &lt;em&gt;type&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-warming with a rate budget, not just a queue.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Sequential with 4s gap — 12 signs × 4s = ~48s total, stays within 15 RPM free limit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Moving to Cloudflare Workers: the gotcha nobody's docs mention
&lt;/h2&gt;

&lt;p&gt;The migration itself, via &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;, was mostly straightforward — &lt;code&gt;next build&lt;/code&gt; differences aside, App Router deploys cleanly to Workers. Two things were genuinely worth knowing in advance:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hyperdrive wants the &lt;em&gt;unpooled&lt;/em&gt; database connection.&lt;/strong&gt; 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 &lt;em&gt;direct&lt;/em&gt; connection string, not the pooled one, and let Hyperdrive be the only pooler in the path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;opennextjs-cloudflare deploy&lt;/code&gt; does not rebuild your app.&lt;/strong&gt; This one cost a genuinely confusing 20 minutes. The deploy command uploads whatever's already sitting in &lt;code&gt;.open-next/&lt;/code&gt; — it does not invoke &lt;code&gt;next build&lt;/code&gt; first. Ship a code change, forget to run &lt;code&gt;cf:build&lt;/code&gt; immediately before &lt;code&gt;cf:deploy&lt;/code&gt;, 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 &lt;code&gt;.open-next/&lt;/code&gt; build timestamp before you go looking for a routing bug that isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape of stack
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want to see where all this lands for an actual user: &lt;a href="https://astromystra.com" rel="noopener noreferrer"&gt;astromystra.com&lt;/a&gt; — Vedic and Western birth charts, AI-generated readings in English and Hindi, all built on the stack above.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>cloudflare</category>
      <category>ai</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Why I Built a Browser-Only Tool Platform (and What Broke That Rule)</title>
      <dc:creator>Amritanshu Gaurav</dc:creator>
      <pubDate>Mon, 17 Aug 2026 14:20:10 +0000</pubDate>
      <link>https://dev.to/amritanshu_gaurav_8c70b9e/why-i-built-a-browser-only-tool-platform-and-what-broke-that-rule-16df</link>
      <guid>https://dev.to/amritanshu_gaurav_8c70b9e/why-i-built-a-browser-only-tool-platform-and-what-broke-that-rule-16df</guid>
      <description>&lt;p&gt;Every "free online tool" site has the same shape: pick a task — resize an image, merge a PDF, decode a JWT — upload a file, wait, download the result. That upload almost never needs to happen. Browsers have had the APIs to do this work locally for years. The upload step exists because it's the easiest way to build the product, not because the task requires a server.&lt;/p&gt;

&lt;p&gt;That's the premise behind &lt;a href="https://tabreon.com" rel="noopener noreferrer"&gt;Tabreon&lt;/a&gt;: 30 tools across text, developer, QR, image, and PDF categories, all running as client-side JavaScript in the tab. No install, no account, and — for 28 of the 30 — no file ever leaves your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: browser-first by default
&lt;/h2&gt;

&lt;p&gt;Early in the project this became an actual architectural constraint, not just a pitch. Every new tool has to justify why it can't run client-side before it's allowed to touch a server. In this codebase that's written down as an ADR (architecture decision record), and it's held for the vast majority of the catalog:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text tools (word/character/line counters, case conversion, diff checking) — pure string manipulation, trivially client-side.&lt;/li&gt;
&lt;li&gt;Developer tools (JSON formatting, Base64, JWT decoding, hashing, regex testing) — the Web Crypto API and standard JS cover almost all of it.&lt;/li&gt;
&lt;li&gt;PDF tools (merge, split, rotate, watermark, page extraction) — pdf-lib and pdfjs-dist run fine in a browser tab.&lt;/li&gt;
&lt;li&gt;QR generation — qr-code-styling, dynamically imported so it doesn't bloat the initial bundle.&lt;/li&gt;
&lt;li&gt;Most image tools (resize, compress, format conversion between JPEG/PNG/WebP/AVIF, crop) — Canvas and the various browser image codecs handle this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine logic for all of this lives in its own layer (lib/engines/), separate from the UI components. That split isn't just tidiness — it means the actual transform (the JSON parser, the hash function, the PDF page manipulation) is unit-testable without touching React at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the rule breaks
&lt;/h2&gt;

&lt;p&gt;Two tools don't fit: HEIC conversion (the format iPhones save photos in) and SVG-to-PNG rasterization. Browsers don't ship a general HEIC decoder — Apple's format is patent-encumbered in a way that's kept it out of the standard web codec set — and reliable SVG rasterization at scale has its own edge cases (external references, script content, malformed markup) that are safer to handle in a controlled server environment than to trust to a canvas element across every browser.&lt;/p&gt;

&lt;p&gt;Rather than fake a client-side implementation or quietly bolt on a server endpoint and call the whole platform "private" anyway, this became an explicit, narrow exception to the same ADR: a tool can go server-side only if there's genuinely no practical browser path, and even then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The endpoint is rate-limited per visitor.&lt;/li&gt;
&lt;li&gt;The file is used only to produce the converted output.&lt;/li&gt;
&lt;li&gt;Nothing is logged or retained after the response is sent.&lt;/li&gt;
&lt;li&gt;The exception is disclosed, not hidden — it's called out in the privacy policy and now in the README, not folded into a blanket "everything's private" claim.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That distinction — disclosed exception vs. silent violation — mattered more to me than keeping a clean "100% client-side" marketing line. A privacy claim that's true for 28 out of 30 tools and honest about the other 2 is more trustworthy than one that's true for 30 out of 30 in the pitch deck and false in the network tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js (App Router) + TypeScript strict&lt;/li&gt;
&lt;li&gt;Tailwind CSS v4 for styling&lt;/li&gt;
&lt;li&gt;Cloudflare Workers via OpenNext for hosting — the whole app, including the two server-side conversion routes, runs on Workers rather than a traditional Node backend&lt;/li&gt;
&lt;li&gt;Cloudflare D1 for the small set of features that genuinely need a database — feedback, early-access signups, feature-request voting — again, disclosed as exactly what they are rather than bundled into vague "we may collect data" language&lt;/li&gt;
&lt;li&gt;Vitest for unit tests, Playwright for e2e — 561 unit tests at last count, covering the engine layer specifically because that's the code where a silent regression (a PDF merge that quietly drops a page, a hash function that's off by one byte) is the kind of bug that erodes trust fastest&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One genuinely annoying discovery along the way: building the Workers deployment target on native Windows fails with pnpm symlink permission errors inside node_modules/.pnpm — not a code bug, a Windows/pnpm/OpenNext interaction. Building from WSL2, Linux, or CI sidesteps it entirely. Leaving that here in case it saves someone else a debugging afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The tool catalog keeps growing — a text diff checker and the two format-conversion tools above are the most recent additions. Longer-term, there's a planned URL-shortener/analytics cluster that would introduce an optional paid tier, but the browser-based catalog that exists today stays free permanently — nothing that ships free moves behind a paywall later.&lt;/p&gt;

&lt;p&gt;If you try it and something feels rough, or a tool you reach for regularly online is missing, I'd genuinely like to hear about it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tabreon.com" rel="noopener noreferrer"&gt;tabreon.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
