<?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>Forem: Gary Stupak</title>
    <description>The latest articles on Forem by Gary Stupak (@garyedgekits).</description>
    <link>https://forem.com/garyedgekits</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3791068%2F6640f77e-e345-4d29-a048-3ae3c4b1939b.jpg</url>
      <title>Forem: Gary Stupak</title>
      <link>https://forem.com/garyedgekits</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/garyedgekits"/>
    <language>en</language>
    <item>
      <title>Stop Redeploying to Update Translations: Granular Edge Cache Invalidation with Cloudflare Purge API</title>
      <dc:creator>Gary Stupak</dc:creator>
      <pubDate>Mon, 27 Apr 2026 14:41:24 +0000</pubDate>
      <link>https://forem.com/garyedgekits/stop-redeploying-to-update-translations-granular-edge-cache-invalidation-with-cloudflare-purge-api-2cm7</link>
      <guid>https://forem.com/garyedgekits/stop-redeploying-to-update-translations-granular-edge-cache-invalidation-with-cloudflare-purge-api-2cm7</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa9b09x5j7qmxdsxqexfh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa9b09x5j7qmxdsxqexfh.jpg" alt="Edge-Native i18n architecture diagram showing global Cloudflare Workers network with decoupled TRANSLATION_UPDATE JSON deployment - the core concept of granular edge cache invalidation via Cloudflare Purge API for Astro i18n." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edge-Native i18n with Astro &amp;amp; Cloudflare Workers - Part 3&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-1-5b38"&gt;Part 1&lt;/a&gt;, I made a bold promise. Translations, I argued, are not code - they are &lt;strong&gt;data&lt;/strong&gt;. Your Worker shouldn't care whether you support two languages or fifty. Adding a typo fix to a German translation shouldn't feel like shipping a software release.&lt;/p&gt;

&lt;p&gt;I genuinely believed I had delivered on that promise. The architecture stored translations in Cloudflare KV, cached them at the edge, and invalidated stale entries via content-based hashing. &lt;code&gt;TRANSLATIONS_VERSION&lt;/code&gt; - a SHA hash of the translation bundle - was baked into the Worker as a build-time constant and embedded into every cache key. Change a string, regenerate the hash, and all old cache entries became invisible. Clean, deterministic, content-driven.&lt;/p&gt;

&lt;p&gt;Then I deployed the EdgeKits website to production and noticed something uncomfortable.&lt;/p&gt;

&lt;p&gt;I wanted to tweak the hero heading on the Spanish landing page. But the only way to push that change was to run &lt;code&gt;npm run i18n:migrate&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; redeploy the Worker. Because the hash constant lived inside the Worker bundle, updating the hash meant rebuilding the entire application - every time, for every translation change.&lt;/p&gt;

&lt;p&gt;The architecture shipped translations as data. But it &lt;strong&gt;invalidated them as code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the kind of coupling you only notice after you start living with a system. It's subtle. It works. It even works well. But it quietly contradicts the very philosophy the architecture was designed to embody.&lt;/p&gt;

&lt;h2&gt;
  
  
  Untangling Translations from Deployments: What We'll Build
&lt;/h2&gt;

&lt;p&gt;In this article, I'll walk through how I untangled that coupling. We'll visit three intermediate architectures, each of which solved one problem while revealing the next.&lt;/p&gt;

&lt;p&gt;We'll talk about why &lt;code&gt;wrangler deploy --var&lt;/code&gt; isn't actually separate from a deployment. Why storing the version in KV creates a mandatory read on every request. Why caching that version with a short TTL scales poorly across Cloudflare's global edge.&lt;/p&gt;

&lt;p&gt;And finally, why the right answer was to stop trying to be clever about cache keys - and start being explicit about cache invalidation.&lt;/p&gt;

&lt;p&gt;By the end of this piece, we'll have an architecture where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Updating a translation requires exactly one command: &lt;code&gt;npm run i18n:migrate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No Worker redeployment is triggered, ever.&lt;/li&gt;
&lt;li&gt;The edge cache is invalidated surgically - only the namespaces that actually changed are purged, while the rest stay warm.&lt;/li&gt;
&lt;li&gt;The hot path performs &lt;strong&gt;zero KV reads&lt;/strong&gt; and a single cache lookup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We'll get there by using a part of the Cloudflare platform that most developers associate with static assets, not with i18n: the &lt;strong&gt;Cache Purge API&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A note on the original architecture before we proceed. &lt;a href="https://dev.to/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-1-5b38"&gt;Part 1&lt;/a&gt; and &lt;a href="https://dev.to/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-2-359n"&gt;Part 2&lt;/a&gt; describe a real, working system. If you've already built on it, you haven't built on a broken foundation - you've built on a simpler one with a narrower valid use case.&lt;/p&gt;

&lt;p&gt;I kept the original implementation available as a separate branch (&lt;a href="https://github.com/EdgeKits/astro-edgekits-core/tree/v1-version-based-cache" rel="noopener noreferrer"&gt;&lt;code&gt;v1-version-based-cache&lt;/code&gt;&lt;/a&gt;) because it's still the right choice for certain projects: sites deployed on &lt;code&gt;*.workers.dev&lt;/code&gt; subdomains (where Purge API isn't available), projects that don't want to manage API tokens, or solo builds where translation changes are rare. We'll revisit this trade-off explicitly at the end.&lt;/p&gt;

&lt;p&gt;But for anything that ships to a custom domain through Cloudflare - and especially for any project where translations will be updated independently from code - the architecture in this article is what you actually want.&lt;/p&gt;

&lt;p&gt;Let's start by looking at exactly where the original approach quietly breaks its own promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of Translation-Deploy Coupling
&lt;/h2&gt;

&lt;p&gt;Before we fix something, we need to look at it closely enough to see why it's broken. And the tricky part about the original &lt;code&gt;TRANSLATIONS_VERSION&lt;/code&gt; approach is that on the surface, it looks like it solves exactly the problem we wanted to solve.&lt;/p&gt;

&lt;p&gt;Let me walk through what the architecture actually does, step by step.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;npm run i18n:bundle&lt;/code&gt;, the build script reads every JSON file under &lt;code&gt;./locales/&lt;/code&gt;, computes a SHA hash of the entire collected payload, and writes that hash into a generated TypeScript file:&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;// src/domain/i18n/runtime-constants.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TRANSLATIONS_VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;01b7fd54fe04&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fetchTranslations&lt;/code&gt; function then imports this constant at build time and embeds it into every cache key:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PROJECT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:i18n:v&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TRANSLATIONS_VERSION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;namespaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a cached entry might look like &lt;code&gt;edgekits.dev:i18n:v01b7fd54fe04:en:common,landing&lt;/code&gt;. The theory is clean: change a translation, regenerate the hash, and all old cache entries become addressed by a stale key that nothing will ever ask for again. Orphaned, sure - but invisible. Cloudflare's LRU (Least Recently Used - a cache management algorithm) eviction will clean them up eventually.&lt;/p&gt;

&lt;h3&gt;
  
  
  How TRANSLATIONS_VERSION Behaves in Production
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;TRANSLATIONS_VERSION&lt;/code&gt; is a constant compiled into the Worker bundle.&lt;/strong&gt; It lives in JavaScript that gets shipped during &lt;code&gt;wrangler deploy&lt;/code&gt;. Which means: the only way to change its value at runtime is to rebuild the Worker and deploy it again.&lt;/p&gt;

&lt;p&gt;So the promised workflow of "edit JSON → push to KV → users see the update" doesn't actually work. What actually happens is this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You edit &lt;code&gt;en/landing.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;You run &lt;code&gt;npm run i18n:migrate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The script pushes new translations to KV.&lt;/li&gt;
&lt;li&gt;The script regenerates &lt;code&gt;runtime-constants.ts&lt;/code&gt; with a new hash.&lt;/li&gt;
&lt;li&gt;... but the deployed Worker is still running with the &lt;strong&gt;old&lt;/strong&gt; hash in memory.&lt;/li&gt;
&lt;li&gt;So all edge requests continue building cache keys with &lt;code&gt;v01b7fd54fe04&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;And all existing cache entries continue being served - with the old content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Until you redeploy the Worker, the hash in production doesn't change. Period.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The translation update and the cache invalidation are two physically separate events.&lt;/strong&gt; One is a KV write. The other is a code deployment. And the architecture, despite its elegance, silently requires both.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mental Model Mismatch
&lt;/h3&gt;

&lt;p&gt;This is the gap between what the architecture &lt;em&gt;looks&lt;/em&gt; like it does and what it &lt;em&gt;actually&lt;/em&gt; does.&lt;/p&gt;

&lt;p&gt;It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;edit JSON  →  i18n:migrate  →  users see update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In reality, it's this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;edit JSON  →  i18n:migrate  →  npm run deploy  →  users see update
                                       ↑
                            this step is not optional
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz4m0v00fnfvsv1mx7p5v.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz4m0v00fnfvsv1mx7p5v.jpg" alt="Mental model versus reality diagram for TRANSLATIONS_VERSION cache invalidation: in theory, editing JSON propagates directly to users; in practice, the cache serves the old hash until a full Worker redeploy rotates the cache key." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For solo projects where a developer is the only person touching both code and translations, that second step is easy to forget about. It happens naturally during the normal development loop.&lt;/p&gt;

&lt;p&gt;But the moment translations become something a non-developer should be able to update - a content editor, a marketing teammate, a translator working in another timezone - the coupling becomes a real problem. You can't hand someone a command that requires a full application redeploy and call it a content workflow.&lt;/p&gt;

&lt;p&gt;And this isn't a matter of "just automate the deploy step." Even if we automated it, we'd still be redeploying the entire Worker every time someone fixes a German typo. That's not decoupling translations from code. That's just hiding the coupling behind automation.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Decoupling Translations Actually Requires
&lt;/h3&gt;

&lt;p&gt;What we actually want is a system where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Translation updates are a pure data operation - never a code operation.&lt;/li&gt;
&lt;li&gt;The mechanism that tells the Worker "this content is stale" lives outside the Worker bundle.&lt;/li&gt;
&lt;li&gt;That mechanism can be triggered from a local script or a CI job with no Wrangler involvement beyond authenticated API calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of this article is a walk through the architectural dead-ends I hit while trying to satisfy these three requirements, and the eventual solution that made all three possible at once. Each dead-end taught me something specific about the Cloudflare platform - and, honestly, about my own assumptions about where state should live on the edge.&lt;/p&gt;

&lt;p&gt;Let's start with the most obvious fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Attempt - Version Variable via &lt;code&gt;wrangler deploy --var&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The obvious first move was to get &lt;code&gt;TRANSLATIONS_VERSION&lt;/code&gt; out of the compiled bundle and into something the Worker reads dynamically. Cloudflare has a feature that looks like exactly that: environment variables configurable per deployment. And Wrangler has a CLI flag for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wrangler deploy &lt;span class="nt"&gt;--var&lt;/span&gt; TRANSLATIONS_VERSION:01b7fd54fe04
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea writes itself. The Worker reads the version from &lt;code&gt;env.TRANSLATIONS_VERSION&lt;/code&gt; instead of importing a constant. The &lt;code&gt;i18n:migrate&lt;/code&gt; script computes the new hash, pushes translations to KV, and then invokes &lt;code&gt;wrangler deploy --var&lt;/code&gt; to update just the variable. No code changes, no bundle rebuild - just a configuration update.&lt;/p&gt;

&lt;p&gt;Clean. Minimal. Lets me keep nearly all of the existing cache key logic. Let's try it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why wrangler deploy --var Isn't a Real Decoupling
&lt;/h3&gt;

&lt;p&gt;First red flag came before I even ran the command. &lt;code&gt;wrangler deploy --var&lt;/code&gt; is still called &lt;code&gt;deploy&lt;/code&gt;. And it's not a marketing choice - it genuinely creates a new entry in your Worker's Deployments history. Every time you run it, Cloudflare logs a new deployment record, complete with a version ID and a timestamp.&lt;/p&gt;

&lt;p&gt;So even though no JavaScript has changed, the platform thinks you've just shipped new code. Open your Workers dashboard a few days after a handful of translation updates and you'll see something like this in the Deployments list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Version 47   Deployed 2 minutes ago    TRANSLATIONS_VERSION updated
Version 46   Deployed 10 minutes ago   TRANSLATIONS_VERSION updated
Version 45   Deployed 1 hour ago       TRANSLATIONS_VERSION updated
Version 44   Deployed 3 hours ago      TRANSLATIONS_VERSION updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not decoupling translations from deployments. This is &lt;strong&gt;renaming&lt;/strong&gt; a deployment as a translation update and hoping nobody notices.&lt;/p&gt;

&lt;p&gt;And there's a practical problem underneath the conceptual one: your actual code deployments - the real ones, with bug fixes and features - are now buried in a sea of translation-update deployments. If something breaks in production and you need to roll back, your rollback history is polluted with entries that have nothing to do with code changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  How wrangler.jsonc Overwrites CLI Variables
&lt;/h3&gt;

&lt;p&gt;Even setting aside the dashboard noise, there's a more serious issue waiting.&lt;/p&gt;

&lt;p&gt;Variables set via &lt;code&gt;wrangler deploy --var&lt;/code&gt; are &lt;strong&gt;transient with respect to your repository&lt;/strong&gt;. On the next regular deployment - the one where you're actually shipping code - Wrangler reads &lt;code&gt;wrangler.jsonc&lt;/code&gt;, sees the &lt;code&gt;vars&lt;/code&gt; block that defines your environment variables, and overwrites whatever was set by the CLI flag.&lt;/p&gt;

&lt;p&gt;So the flow becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You run &lt;code&gt;npm run i18n:migrate&lt;/code&gt; → &lt;code&gt;wrangler deploy --var TRANSLATIONS_VERSION:abc123&lt;/code&gt;. Hash is now &lt;code&gt;abc123&lt;/code&gt; in production.&lt;/li&gt;
&lt;li&gt;Later that day, you fix a bug and run &lt;code&gt;npm run deploy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Wrangler reads &lt;code&gt;wrangler.jsonc&lt;/code&gt;, which still has the old hash in its &lt;code&gt;vars&lt;/code&gt; block.&lt;/li&gt;
&lt;li&gt;Your bug fix ships. And your translation version silently reverts to the old hash.&lt;/li&gt;
&lt;li&gt;Edge caches that had the new content addressed under &lt;code&gt;abc123&lt;/code&gt; become unreachable. The old version starts serving again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You could, in theory, keep &lt;code&gt;wrangler.jsonc&lt;/code&gt; in sync by rewriting it from the &lt;code&gt;i18n:migrate&lt;/code&gt; script. But now you have a build script that modifies a committed config file, which means every translation update produces a git diff that your developers have to either commit or discard. Congratulations, translations are now polluting your version control too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Approach Can't Work
&lt;/h3&gt;

&lt;p&gt;The root issue is this: &lt;strong&gt;Cloudflare environment variables are bound to the lifecycle of the Worker, not to the lifecycle of the translations.&lt;/strong&gt; &lt;code&gt;wrangler.jsonc&lt;/code&gt; is the source of truth for Worker configuration. Anything you push via &lt;code&gt;--var&lt;/code&gt; is a temporary override that gets washed away on the next real deploy.&lt;/p&gt;

&lt;p&gt;This makes complete sense from a platform design perspective. Environment variables are meant to describe &lt;em&gt;how the Worker is configured&lt;/em&gt;, not &lt;em&gt;what data the Worker is currently serving&lt;/em&gt;. Stuffing content versioning into that slot is fighting the abstraction.&lt;/p&gt;

&lt;p&gt;What I actually wanted was the opposite: a piece of state that belongs to the &lt;strong&gt;translations&lt;/strong&gt;, not to the Worker. State that survives code deployments, gets updated by the &lt;code&gt;i18n:migrate&lt;/code&gt; script, and is readable at the edge without requiring a Wrangler command to modify it.&lt;/p&gt;

&lt;p&gt;Which brings us to the next obvious question. Cloudflare already has a perfect place to store translation-adjacent state. It's called KV. It's where the translations themselves already live. Why not just put the version there?&lt;/p&gt;

&lt;h2&gt;
  
  
  Translations as First-Class KV Citizens
&lt;/h2&gt;

&lt;p&gt;If the problem with &lt;code&gt;wrangler deploy --var&lt;/code&gt; is that Cloudflare environment variables are tied to the Worker's lifecycle rather than the translations' lifecycle, the fix seems obvious: stop using environment variables. Use KV instead.&lt;/p&gt;

&lt;p&gt;KV is where the translations already live. Adding one more key to hold the version - something like &lt;code&gt;&amp;lt;project&amp;gt;:meta:version&lt;/code&gt; - keeps everything in the same storage layer, updatable by the same script, readable by the same runtime. No Wrangler involvement. No dashboard noise. No &lt;code&gt;wrangler.jsonc&lt;/code&gt; to keep in sync.&lt;/p&gt;

&lt;p&gt;The flow becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;i18n:migrate&lt;/code&gt; pushes translations to KV.&lt;/li&gt;
&lt;li&gt;In the same batch write, it updates &lt;code&gt;&amp;lt;project&amp;gt;:meta:version&lt;/code&gt; with the new hash.&lt;/li&gt;
&lt;li&gt;The Worker reads the version from KV at request time and uses it to construct cache keys.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let me actually try this and see where it breaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  The First Implementation
&lt;/h3&gt;

&lt;p&gt;Here's the simplest version. The fetcher reads the version as its first KV operation:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;versionKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PROJECT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:meta:version`&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;version&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TRANSLATIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;versionKey&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;cacheId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PROJECT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:i18n:v&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;namespaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&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;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheRequest&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;cached&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;cached&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kvResults&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TRANSLATIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;namespaceKeys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compile it, deploy it, run &lt;code&gt;i18n:migrate&lt;/code&gt; to push a change. Open the site. Updates appear immediately, exactly as promised before. No redeployment needed. The content lives its own life.&lt;/p&gt;

&lt;p&gt;Job done?&lt;/p&gt;

&lt;h3&gt;
  
  
  The Hot Path Regression
&lt;/h3&gt;

&lt;p&gt;Look at what we just did to the hot path.&lt;/p&gt;

&lt;p&gt;Previously - with &lt;code&gt;TRANSLATIONS_VERSION&lt;/code&gt; compiled into the bundle - a cached request looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache.match(request)  →  HIT  →  return
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One cache lookup. Zero KV reads. This was the whole point of caching in the first place.&lt;/p&gt;

&lt;p&gt;Now it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;KV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta:version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="err"&gt;→&lt;/span&gt;  &lt;span class="nx"&gt;version&lt;/span&gt;       &lt;span class="c1"&gt;// KV read #1&lt;/span&gt;
&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="err"&gt;→&lt;/span&gt;  &lt;span class="nx"&gt;HIT&lt;/span&gt;           &lt;span class="c1"&gt;// cache lookup&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single request - even ones that would have been served entirely from the edge cache - now performs a mandatory KV read just to discover what version number to put in the cache key. We traded "no cache invalidation without redeploying" for "every request costs a KV read."&lt;/p&gt;

&lt;p&gt;For a site with real traffic, this is not a minor regression. KV reads are billable. And more importantly, they add latency. An edge cache hit on Cloudflare is sub-millisecond. A KV read, even when it's fast, is a round-trip to the nearest replica. We just inserted that round-trip into every page load, for no user-facing benefit - the user would have gotten the cached response anyway.&lt;/p&gt;

&lt;p&gt;So the naive KV approach traded one problem (coupling to code deploys) for another (coupling cache lookup to a mandatory KV read).&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempting to Cache the Version Too
&lt;/h3&gt;

&lt;p&gt;The obvious next move: if the problem is reading the version from KV on every request, cache the version. Store it in the Cache API with a short TTL, and only fall back to KV when the cache expires:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;versionCacheRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PROJECT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/meta:version`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;versionResponse&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;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;versionCacheRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;versionResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;version&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;versionResponse&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;version&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TRANSLATIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;versionKey&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&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="s1"&gt;public, s-maxage=60&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;versionCacheRequest&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short TTL so that translation updates propagate within about a minute. Cache API handles the edge distribution. KV reads happen at most once per minute per edge node. This seems to solve it - the hot path is back to a single cache lookup for the version, plus the existing cache lookup for the translations.&lt;/p&gt;

&lt;p&gt;But pause and think about what "once per minute per edge node" actually means at global scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Free Tier Math
&lt;/h3&gt;

&lt;p&gt;Cloudflare operates a global network of data centers. Each one maintains its own cache. With a 60-second TTL, each data center will perform one KV read per minute, per cache key, for as long as there's traffic hitting it.&lt;/p&gt;

&lt;p&gt;A back-of-the-envelope calculation: 60 seconds in a minute × 60 minutes in an hour × 24 hours = 86,400 seconds in a day. At a 60-second TTL, that's 1,440 revalidations per edge node per day. Multiply that by the number of edge nodes that actually see traffic for your site, which for a moderately popular site could be dozens or more.&lt;/p&gt;

&lt;p&gt;Free tier on Workers KV allows 100,000 reads per day. You can blow through that surprisingly quickly with only the &lt;strong&gt;version key&lt;/strong&gt; - and that's before you've counted the actual translation reads. For a content-heavy site with traffic spread across many regions, even a paid plan starts to look expensive when every namespace load requires a mandatory version-check KV read.&lt;/p&gt;

&lt;p&gt;You could lengthen the TTL - five minutes, ten minutes - but now translation updates propagate slowly and unpredictably. You could shorten it - five seconds - and now you're hammering KV constantly. There's no sweet spot that's actually good. You're just picking which trade-off hurts less.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Double Cache Lookup Problem
&lt;/h3&gt;

&lt;p&gt;There's also a subtler issue that's less about cost and more about architectural smell.&lt;/p&gt;

&lt;p&gt;Every request now does &lt;strong&gt;two&lt;/strong&gt; cache lookups in sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="err"&gt;→&lt;/span&gt;  &lt;span class="nx"&gt;HIT&lt;/span&gt;       &lt;span class="c1"&gt;// lookup #1&lt;/span&gt;
&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;translations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="err"&gt;→&lt;/span&gt;  &lt;span class="nx"&gt;HIT&lt;/span&gt;       &lt;span class="c1"&gt;// lookup #2&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These can't be parallelized. The second lookup depends on the result of the first, because the version is used to construct the cache key for the translations. And while a cache lookup is fast, &lt;em&gt;two&lt;/em&gt; sequential cache lookups is twice as slow as &lt;em&gt;one&lt;/em&gt; - and we just doubled the hot-path latency for the explicit purpose of enabling cache invalidation.&lt;/p&gt;

&lt;p&gt;At this point, it started to feel like I was fighting the platform. Each layer of caching I added to work around the previous layer's limitations introduced its own limitations, each requiring another layer. The system was getting more complex, not less.&lt;/p&gt;

&lt;p&gt;That's usually a sign I'm approaching the problem wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stepping Back
&lt;/h3&gt;

&lt;p&gt;Let me restate the original problem from scratch.&lt;/p&gt;

&lt;p&gt;I want translation updates to propagate immediately, without redeploying code. I want the hot path to have zero KV reads. I want the cache key to be stable - so I don't pollute the cache with orphaned entries every time content changes.&lt;/p&gt;

&lt;p&gt;The approaches we've tried all operate on the same assumption: &lt;strong&gt;the cache key encodes information about content versions.&lt;/strong&gt; Embed the hash, and invalidation happens automatically when the hash changes. But automatic invalidation via key rotation has a cost, and that cost is either a redeploy, a mandatory KV read, or a double cache lookup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm0p9timpa8ey4a80k8ay.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm0p9timpa8ey4a80k8ay.jpg" alt="Comparison table of failed edge cache invalidation approaches for Cloudflare Workers: wrangler deploy --var clutters deployment history, reading version from KV adds cost and latency, short TTL cache causes edge cache thrashing." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What if I flip the assumption? What if the cache key doesn't encode version at all? What if cache entries are &lt;strong&gt;never&lt;/strong&gt; orphaned by content changes - because the key is static - and I invalidate them some other way?&lt;/p&gt;

&lt;p&gt;That's when I started reading the Cloudflare Cache docs for something I'd been ignoring the whole time: not how to &lt;em&gt;build&lt;/em&gt; cache keys, but how to &lt;em&gt;destroy&lt;/em&gt; cache entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Breakthrough - Static Keys + Explicit Invalidation
&lt;/h2&gt;

&lt;p&gt;Every approach we've tried so far shares the same underlying pattern: &lt;strong&gt;the cache key contains a version marker, and we invalidate by rotating that marker&lt;/strong&gt;. Content changes → hash changes → cache key changes → old entries become orphaned → new entries get created under a new key.&lt;/p&gt;

&lt;p&gt;This is a &lt;em&gt;passive&lt;/em&gt; invalidation strategy. Nothing actively removes stale entries; we just stop addressing them. They sit around until Cloudflare's LRU policy decides to evict them. The cache fills up with ghosts.&lt;/p&gt;

&lt;p&gt;The alternative is &lt;strong&gt;active&lt;/strong&gt; invalidation: the cache key stays stable across content changes, and when translations update, we explicitly tell Cloudflare to delete the affected entries.&lt;/p&gt;

&lt;p&gt;Once I stated it that way, it became obvious that I'd been solving the wrong problem. I'd been trying to make cache keys carry versioning information. But cache keys are identifiers, not metadata. Their job is to answer "which piece of content is this?" - not "when was it last modified?" Versioning information belongs somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7ran6ru2m8liam3mkfw3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7ran6ru2m8liam3mkfw3.jpg" alt="Passive versus active cache invalidation comparison: passive approach relies on rotating cache keys and LRU eviction (slow, wasteful); active approach uses static keys with surgical Cloudflare Purge API invalidation (instant, precise)." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The New Cache Key Shape
&lt;/h3&gt;

&lt;p&gt;If the key doesn't need to carry a version, it becomes simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;i18n:&amp;lt;locale&amp;gt;:&amp;lt;namespace&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the logical identifier. Wrapped into the URL shape that Cloudflare's Cache API expects, it becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://&amp;lt;PROJECT.id&amp;gt;/&amp;lt;encoded-identifier&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One key per &lt;code&gt;locale:namespace&lt;/code&gt; pair. Stable forever. The same key that stores the Spanish landing translations today will store them in a year - whatever version "today" happens to be.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9g2rly7tmh3jigi61qku.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9g2rly7tmh3jigi61qku.jpg" alt="Per-namespace translation cache key structure on Cloudflare Workers: https://PROJECT_ID/i18n:locale:namespace format with infinite TTL via Cache-Control s-maxage=31536000 immutable directive and individual namespace isolation." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's another change baked into this shape that I glossed over in the earlier examples. Previously, the cache key encoded a &lt;strong&gt;comma-joined list of namespaces&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;i18n:v&amp;lt;hash&amp;gt;:&amp;lt;locale&amp;gt;:&amp;lt;ns1,ns2,ns3&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every unique combination of namespaces requested by a page produced a unique cache entry. A page asking for &lt;code&gt;common,landing&lt;/code&gt; created one entry; a page asking for &lt;code&gt;common,landing,newsletter&lt;/code&gt; created a completely separate entry, even though two-thirds of the content overlapped. Same translations, cached three times under three different keys.&lt;/p&gt;

&lt;p&gt;With static per-namespace keys, each namespace is its own cache entry. A page that needs &lt;code&gt;common,landing,newsletter&lt;/code&gt; does three parallel cache lookups, assembles the result, and any other page requesting &lt;code&gt;common,landing&lt;/code&gt; gets cache hits on both - the namespaces are shared across requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Hot Path Looks Like Now
&lt;/h3&gt;

&lt;p&gt;Let me walk through a realistic request with this architecture.&lt;/p&gt;

&lt;p&gt;A user hits &lt;code&gt;/es/blog/some-article&lt;/code&gt;. The page needs &lt;code&gt;common&lt;/code&gt;, &lt;code&gt;blog&lt;/code&gt;, and &lt;code&gt;newsletter&lt;/code&gt; namespaces. The fetcher issues three cache lookups in parallel:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheResults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;namespaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildTranslationCacheRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&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;cached&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;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;cached&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgdvffsfidg6vhyhrnycw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgdvffsfidg6vhyhrnycw.jpg" alt="Request pipeline for parallel namespace resolution on Cloudflare Workers edge cache: incoming request fans out to three parallel cache lookups, full hit path returns with zero KV reads, partial miss path issues single batched KV request with latency bounded to slowest lookup." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If all three are in the cache - &lt;code&gt;FULL HIT&lt;/code&gt; - the function returns immediately. Zero KV reads. One round of parallel cache lookups, not a sequential chain. The total latency is bounded by the slowest of the three parallel lookups, not their sum.&lt;/p&gt;

&lt;p&gt;If one namespace is missing - say &lt;code&gt;blog&lt;/code&gt; was recently invalidated - the fetcher filters down to just the missing ones and issues a single KV batch call:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cacheResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ns&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;kvKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;buildTranslationKvKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&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;kvBatch&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TRANSLATIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kvKeys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One KV batch. One call regardless of how many namespaces are missing. Results get merged with the cache hits, written back to the cache, and returned.&lt;/p&gt;

&lt;p&gt;This is genuinely better than both previous architectures on every metric I care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hot path:&lt;/strong&gt; zero KV reads, one parallel cache roundtrip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial miss:&lt;/strong&gt; exactly the missing namespaces fetched, in one batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache bloat:&lt;/strong&gt; none - each &lt;code&gt;locale:namespace&lt;/code&gt; is exactly one cache entry, period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version tracking overhead:&lt;/strong&gt; zero - there's no version to track at the request layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How Do Translation Updates Reach Users Without Key Rotation?
&lt;/h3&gt;

&lt;p&gt;But I haven't addressed the elephant in the room. If cache keys are stable and never change, how does a translation update ever reach users?&lt;/p&gt;

&lt;p&gt;The cache entry for &lt;code&gt;edgekits.dev:i18n:es:landing&lt;/code&gt; stores the Spanish landing translations &lt;em&gt;as of the last time that entry was written&lt;/em&gt;. If I edit that translation in KV and the cache entry is still present, the user keeps seeing the old content. Forever, in principle - we removed the cache TTL because we didn't want arbitrary expiry windows. An entry written once will be served until Cloudflare evicts it for space reasons, which on an active site could be weeks or months.&lt;/p&gt;

&lt;p&gt;So we need a way to, after &lt;code&gt;i18n:migrate&lt;/code&gt; pushes a change to KV, explicitly remove the affected cache entries. Not rotate keys. Not change cache TTLs. Actually delete specific entries from the Cloudflare edge cache.&lt;/p&gt;

&lt;p&gt;The thing is, I'd been vaguely aware this capability existed. Every developer who's used Cloudflare has seen the "Purge Cache" button in the dashboard. It purges static assets. It's what you use when you've just pushed a new version of your CSS and you want everyone to see it immediately. I'd categorized it mentally as "a CDN tool, for static file deploys" - something orthogonal to how I think about Workers and application state.&lt;/p&gt;

&lt;p&gt;What I hadn't registered is that the purge system works on &lt;strong&gt;any URL that's in the Cloudflare cache&lt;/strong&gt; - including URLs that were put there by the Workers Cache API. &lt;code&gt;cache.put(cacheRequest, response)&lt;/code&gt; inside a Worker uses the same underlying storage that serves static assets. And the same API that purges &lt;code&gt;styles.css&lt;/code&gt; can purge an application-level cache entry.&lt;/p&gt;

&lt;p&gt;So the architecture becomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache keys are stable.&lt;/strong&gt; &lt;code&gt;&amp;lt;PROJECT.id&amp;gt;:i18n:&amp;lt;locale&amp;gt;:&amp;lt;namespace&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache entries have effectively infinite TTL.&lt;/strong&gt; &lt;code&gt;Cache-Control: s-maxage=31536000, immutable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalidation is explicit.&lt;/strong&gt; When &lt;code&gt;i18n:migrate&lt;/code&gt; runs, it calls the Cloudflare Purge API and hands it the list of URLs to invalidate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalidation is granular.&lt;/strong&gt; Only the cache entries for the namespaces that actually changed get purged. Everything else stays warm.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the architecture I ended up shipping. The rest of the article is about how to make it actually work - the Purge API mechanics, how to detect which namespaces changed, how to deploy and configure the whole thing, and the trade-offs you should know before adopting it.&lt;/p&gt;

&lt;p&gt;Let's look at the Purge API first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudflare Purge API - The Missing Piece
&lt;/h2&gt;

&lt;p&gt;The Cloudflare Purge API is one of those platform features that most developers know exists but have never actually used from code. It's the mechanism behind the "Purge Cache" button in the dashboard. It's what gets mentioned in passing when someone asks "how do I force a cache refresh." And it's rarely discussed in the context of Workers application architecture, even though it slots in perfectly.&lt;/p&gt;

&lt;p&gt;Let's look at what it actually does and what constraints it imposes.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Purge-by-URL Works
&lt;/h3&gt;

&lt;p&gt;The Purge API has several modes - purge everything, purge by hostname, purge by tag, purge by prefix, and purge by single file. The one we care about is &lt;strong&gt;purge by single file&lt;/strong&gt; (also called purge by URL), which takes a list of specific URLs and invalidates them immediately across Cloudflare's entire edge network.&lt;/p&gt;

&lt;p&gt;The request shape is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://api.cloudflare.com/client/v4/zones/&amp;lt;ZONE_ID&amp;gt;/purge_cache
Authorization: Bearer &amp;lt;API_TOKEN&amp;gt;
Content-Type: application/json

{
  "files": [
    "https://edgekits.dev/i18n%3Aen%3Alanding",
    "https://edgekits.dev/i18n%3Aes%3Alanding"
  ]
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You hand it a list of URLs. It returns success and deletes those exact entries from the edge cache, globally. The next request to each of those URLs results in a cache miss, the Worker falls through to KV, and the fresh content is re-cached under the same key. One API call, global invalidation, takes about a second to propagate.&lt;/p&gt;

&lt;p&gt;There's something worth noticing here: the URLs in the purge request are the same URLs we used as cache keys in the previous section. The &lt;code&gt;https://&amp;lt;PROJECT.id&amp;gt;/&amp;lt;encoded-key&amp;gt;&lt;/code&gt; shape isn't just a convention for &lt;code&gt;cache.put&lt;/code&gt; - it becomes the exact addressing scheme for &lt;code&gt;purge_cache&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the part that ties the whole architecture together. The fetcher writes cache entries under a URL; the migration script deletes cache entries under the same URL. One formula, shared between two files.&lt;/p&gt;

&lt;p&gt;This is why &lt;code&gt;translations-keys.ts&lt;/code&gt; exists as a dedicated module in the implementation. Cache URLs need to be computed identically in two contexts - at request time inside the Worker, and at deploy time in the Node.js migration script. Any drift between the two, and the purge silently misses. Centralizing the formula in one file eliminates that class of bug by construction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate Limits and How They Affect Us
&lt;/h3&gt;

&lt;p&gt;Free-tier Cloudflare accounts get &lt;strong&gt;5 purge requests per minute&lt;/strong&gt;, with a token bucket capacity of 25. Each purge request can include up to &lt;strong&gt;30 URLs&lt;/strong&gt; for the free tier (paid tiers get higher numbers of URLs per request, but the request rate limits are similar or more relaxed).&lt;/p&gt;

&lt;p&gt;Let me put those limits in context for an i18n workload. A typical project has, say, 5 locales and 10 namespaces - that's 50 possible &lt;code&gt;locale:namespace&lt;/code&gt; cache entries total. Even if every single one of them changed simultaneously, that's 50 URLs to purge, which splits into two API calls of 30 + 20. Well within the per-minute request budget.&lt;/p&gt;

&lt;p&gt;In practice, translation updates almost never touch every namespace at once. A typical update changes one or two namespaces in one locale - maybe a typo fix in the Spanish landing page, or a new key added to the German pricing copy. That's one or two URLs per migration, and the rate limit is effectively infinite for that workload.&lt;/p&gt;

&lt;p&gt;The only scenario where rate limits could matter is the &lt;strong&gt;first migration&lt;/strong&gt; on a fresh setup, where no hash file exists yet and every namespace is treated as "changed." We'll come back to this in the next section - the solution is just chunking the purge into batches of 30 URLs with a brief pause between batches.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Proxied-Domain Requirement
&lt;/h3&gt;

&lt;p&gt;There's one architectural constraint that trips people up, and it's worth calling out clearly before you spend an evening debugging it.&lt;/p&gt;

&lt;p&gt;The Purge API operates on Cloudflare's CDN layer. It requires that your domain is &lt;strong&gt;proxied through Cloudflare&lt;/strong&gt; - the orange cloud icon next to your DNS records. If your Worker is only deployed to a &lt;code&gt;*.workers.dev&lt;/code&gt; subdomain, or if your custom domain has the grey cloud (DNS-only mode), neither the Cache API nor the Purge API actually do anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj0vwmqrrip00or549dzf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj0vwmqrrip00or549dzf.png" alt="Cloudflare DNS record for a custom domain showing orange-cloud proxied status required for Workers Cache API and Purge API to function - the Worker binding for edgekits.dev routes through Cloudflare's CDN edge layer." width="722" height="37"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;*.workers.dev&lt;/code&gt; in particular, &lt;code&gt;cache.put()&lt;/code&gt; silently returns without storing, &lt;code&gt;cache.match()&lt;/code&gt; always returns &lt;code&gt;undefined&lt;/code&gt;, and every request falls straight through to KV - because the Workers Cache API is tied to zone-level caching that doesn't exist for the shared &lt;code&gt;workers.dev&lt;/code&gt; subdomain.&lt;/p&gt;

&lt;p&gt;I noticed this while testing the implementation: &lt;code&gt;wrangler tail&lt;/code&gt; showed no cache hits at all on the &lt;code&gt;*.workers.dev&lt;/code&gt; deployment - every single request went to KV. Meanwhile the migration script reported successful purges.&lt;/p&gt;

&lt;p&gt;It took me a while to realize the two observations were related: there was no cache to purge, because there was no cache to begin with. The Purge API was returning success because the request was syntactically valid, but there was nothing in front of the Worker on that URL to invalidate.&lt;/p&gt;

&lt;p&gt;The moment I pointed the same test at the proxied custom domain, everything fell into place. Cache hits started appearing in tail logs. Purge requests actually removed entries. New content propagated within seconds globally.&lt;/p&gt;

&lt;p&gt;This requirement is worth respecting when you decide whether to adopt this architecture. If your project is deployed exclusively on &lt;code&gt;workers.dev&lt;/code&gt; - say, it's an internal tool, or you're in early development and haven't bought a domain yet - this whole approach doesn't apply.&lt;/p&gt;

&lt;p&gt;You have two reasonable alternatives: stick with the content-hash architecture from Part 1 (we'll revisit this in the trade-offs section at the end), or simply disable edge caching entirely by setting &lt;code&gt;I18N_CACHE=off&lt;/code&gt; and read translations directly from KV on every request.&lt;/p&gt;

&lt;p&gt;For a preview deployment with modest traffic, the KV free tier gives you plenty of headroom - 100,000 reads per day is more than enough for most pre-launch projects, and you get perfectly up-to-date content without any invalidation machinery at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  API Token and Zone ID Setup
&lt;/h3&gt;

&lt;p&gt;The Purge API requires an API token with the &lt;code&gt;Cache Purge&lt;/code&gt; permission, scoped to your specific zone. This is a different token than the one Wrangler uses for deployment - and that's actually a good thing. The purge token has minimal permissions: it can only delete cache entries on one zone. Even if it leaks, the blast radius is "an attacker can make your translations briefly uncached," which is annoying but not catastrophic.&lt;/p&gt;

&lt;p&gt;You create the token at &lt;code&gt;dash.cloudflare.com/profile/api-tokens&lt;/code&gt; either via the &lt;code&gt;Cache Purge&lt;/code&gt; template or manually with &lt;code&gt;Zone → Cache Purge → Purge&lt;/code&gt; permissions scoped to your domain. The token then goes into &lt;code&gt;.dev.vars&lt;/code&gt; for local execution of the migration script, and into Worker Secrets for any production-side code that might need it.&lt;/p&gt;

&lt;p&gt;Importantly, the &lt;strong&gt;Zone ID&lt;/strong&gt; is a separate piece of information - it identifies &lt;em&gt;which&lt;/em&gt; zone you're purging, not &lt;em&gt;who&lt;/em&gt; is authorized to purge. Zone IDs are not secrets. You can find yours on the overview page of your Cloudflare domain dashboard, and it's safe to commit to &lt;code&gt;wrangler.jsonc&lt;/code&gt; as a plain &lt;code&gt;vars&lt;/code&gt; entry.&lt;/p&gt;

&lt;p&gt;This distinction matters when you're setting up the project in a public repository: the token stays out of git, but the zone ID can live alongside the rest of your config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Purge API Fits Edge Cache Invalidation
&lt;/h3&gt;

&lt;p&gt;If I'd known how perfectly Purge API's semantics matched what I needed, I would have gone straight here from the start. The API does exactly one thing: delete specific URLs from the edge cache, immediately, globally, at the zone level. That's the whole feature. And "delete specific URLs, immediately, globally" is the exact primitive that was missing from every previous architecture.&lt;/p&gt;

&lt;p&gt;What remains is one detail - a small but important one. When &lt;code&gt;i18n:migrate&lt;/code&gt; runs, we don't want to naively purge every possible translation URL. That would work, but it would cause a cache stampede - every edge node would simultaneously cold-fetch every namespace from KV on the next request to each locale.&lt;/p&gt;

&lt;p&gt;For a project with dozens of namespaces across multiple locales, that's a lot of unnecessary KV reads for no benefit.&lt;/p&gt;

&lt;p&gt;What we want is to purge &lt;strong&gt;only the entries that actually changed&lt;/strong&gt;. And to do that, we need a way to track what "actually changed" means between one run of &lt;code&gt;i18n:migrate&lt;/code&gt; and the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental Purging - The Hash File Strategy
&lt;/h2&gt;

&lt;p&gt;We have two pieces of the architecture in place: static cache keys that never change, and a Purge API that can delete specific URLs on demand. What's missing is the part that decides &lt;strong&gt;which&lt;/strong&gt; URLs to delete when &lt;code&gt;i18n:migrate&lt;/code&gt; runs.&lt;/p&gt;

&lt;p&gt;The naive version is easy: purge every possible &lt;code&gt;locale:namespace&lt;/code&gt; URL every time. It would work, and for a small project with a handful of namespaces it might even be fine. But at any real scale, this approach has a cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why "Purge Everything" Causes a Cache Stampede
&lt;/h3&gt;

&lt;p&gt;Imagine a project with 5 locales and 12 namespaces - that's 60 cache entries total. You fix a typo in &lt;code&gt;en/landing.json&lt;/code&gt;. One file changed. With naive invalidation, all 60 entries get purged.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F38rk1j2rns6mxl5hfov2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F38rk1j2rns6mxl5hfov2.jpg" alt="Granular cache purging at the Cloudflare edge: i18n:migrate command triggers surgical purge of only the changed locale-namespace pair (es:landing) while active cache entries for all other locales and namespaces remain unaffected across the global edge network." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The next time users hit your site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every edge node that had warm cache entries now has cold cache entries.&lt;/li&gt;
&lt;li&gt;Every page load triggers a parallel cache miss.&lt;/li&gt;
&lt;li&gt;Every miss triggers a KV read.&lt;/li&gt;
&lt;li&gt;Multiplied across every edge node globally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a &lt;strong&gt;cache stampede&lt;/strong&gt; - a sudden burst of origin reads triggered by simultaneously invalidating content that was previously hot. For a busy site, that burst can be significant. Across hundreds of edge nodes, a single "fix a typo" operation produces thousands of redundant KV reads to re-populate cache entries that didn't need to change in the first place.&lt;/p&gt;

&lt;p&gt;The cost isn't catastrophic - KV is fast, and this isn't a database hitting its connection limit. But it's wasteful in the exact way that caching was supposed to prevent. We already know that 59 of those 60 namespace-locale pairs are identical to what they were before. Why would we tell every edge node in the world to forget them?&lt;/p&gt;

&lt;p&gt;What we want is: &lt;code&gt;en/landing.json&lt;/code&gt; changed, purge exactly &lt;code&gt;edgekits.dev/i18n:en:landing&lt;/code&gt;, leave the other 59 entries alone. Surgical invalidation. Zero wasted cache evictions. The other locales keep serving warm cache forever - or at least until someone changes them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "Changed" Actually Means
&lt;/h3&gt;

&lt;p&gt;To purge selectively, we need to know which namespaces actually changed between two runs of &lt;code&gt;i18n:migrate&lt;/code&gt;. Python developers might reach for mtimes. Database folks might reach for updated_at columns. But we have something simpler available: the content of the files themselves.&lt;/p&gt;

&lt;p&gt;The idea is this: after every successful migration, we compute a SHA hash of each locale-namespace JSON and store the hashes in a local file. On the next migration, we compute the hashes again and compare. Any pair whose hash differs between the two runs is a pair that changed. Any pair whose hash matches is untouched.&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;// After reading all locale JSON files:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentHashes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&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;locale&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;locales&lt;/span&gt;&lt;span class="p"&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;ns&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;namespaces&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;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;translations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nx"&gt;currentHashes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&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="c1"&gt;// Compare against previous run:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;previousHashes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readHashFile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&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;changedKeys&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="o"&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;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentHashes&lt;/span&gt;&lt;span class="p"&gt;))&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;currentHashes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;previousHashes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;changedKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0viwpyqhf6stf9y8t5yl.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0viwpyqhf6stf9y8t5yl.jpg" alt="Intelligent content diffing pipeline for selective translation cache purging: stableStringify with 12-character SHA hash per locale-namespace pair, compared against .i18n-hashes.json local store to produce changedKeys array fed into the Cloudflare Purge API with graceful retry on failure." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;changedKeys&lt;/code&gt; is now the exact list of &lt;code&gt;locale:namespace&lt;/code&gt; pairs whose content differs from the last migration. Feed those into &lt;code&gt;buildTranslationCacheUrl&lt;/code&gt; and you've got the precise list of URLs to purge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the Hash File Lives
&lt;/h3&gt;

&lt;p&gt;The hash file is &lt;code&gt;.i18n-hashes.json&lt;/code&gt; in the project root. Two important properties:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's local state, not shared state.&lt;/strong&gt; The file records what was &lt;em&gt;last successfully pushed&lt;/em&gt; from your machine. If a teammate runs &lt;code&gt;i18n:migrate&lt;/code&gt; from their machine without having your hash file, their first run will see no previous hashes, treat everything as changed, and purge all URLs - which is the correct safe default for a first run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's gitignored.&lt;/strong&gt; Committing it would be wrong for two reasons. First, it's a representation of state held on a specific machine at a specific time, not something that belongs in a repo. Second, if two developers with different hash files both committed, you'd get merge conflicts on a file nobody should be manually editing. The file is a cache - treat it like any other local cache (the &lt;code&gt;.wrangler/&lt;/code&gt; directory, &lt;code&gt;dist/&lt;/code&gt;, etc.).&lt;/p&gt;

&lt;p&gt;The trade-off is that a fresh clone on a different machine always produces a "purge everything" outcome on its first run. For most projects, this is fine - the worst case is a single cache stampede right after setup, which self-heals within the first few minutes of traffic.&lt;/p&gt;

&lt;p&gt;If you care about avoiding even that, there are options (storing the hash file in your R2 bucket, committing it with a merge-driver-style strategy, etc.), but for the reference implementation I chose the simpler path.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Full i18n:migrate Invalidation Pipeline
&lt;/h3&gt;

&lt;p&gt;Putting everything from this section together, the complete flow of &lt;code&gt;i18n:migrate&lt;/code&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 1. Read all JSON files under ./locales/**.
 2. Compute SHA hashes per locale:namespace pair.
 3. Write i18n-data.json (the KV bulk payload).
 4. Push translations to remote KV via wrangler kv bulk put.
 5. Read .i18n-hashes.json (previous state). Missing file = first run.
 6. Diff against current hashes → produce list of changed keys.
 7. If changedKeys is empty → skip purge. Log "no cache entries to purge."
 8. Otherwise → build Purge URLs via buildTranslationCacheUrl.
 9. Call Cloudflare Purge API, chunking if needed to stay under rate limits.
10. On success → write updated hashes to .i18n-hashes.json.
11. On purge failure → log warning, do not update hash file (retry next time).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 11 is worth pausing on. If KV was updated but the purge call failed - say the API token expired, or we hit a rate limit - we deliberately do &lt;strong&gt;not&lt;/strong&gt; write the updated hash file.&lt;/p&gt;

&lt;p&gt;The reasoning: on the next migration, we want the changed namespaces to still look "changed" relative to the last known good state, so the retry happens automatically.&lt;/p&gt;

&lt;p&gt;This gives us graceful recovery without manual intervention. If &lt;code&gt;i18n:migrate&lt;/code&gt; reports a purge failure, you just run it again - the hash diff will include everything that was supposed to be purged last time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate Limit Arithmetic (Revisited)
&lt;/h3&gt;

&lt;p&gt;Back in the Purge API section, I noted that free tier allows 5 purge requests per minute with up to 30 URLs per request. Let me put this in the context of the incremental strategy.&lt;/p&gt;

&lt;p&gt;In normal operation, a single &lt;code&gt;i18n:migrate&lt;/code&gt; run purges somewhere between 1 and 3 URLs - a content editor fixing copy in one or two namespaces. That's one API call, well inside limits. The rate limit is effectively irrelevant.&lt;/p&gt;

&lt;p&gt;The only situation where you might approach the rate limit is a &lt;strong&gt;fresh setup with a large project&lt;/strong&gt;: no hash file, 10 locales × 20 namespaces = 200 URLs to purge on the first migration. 200 URLs ÷ 30 URLs per request = 7 API calls. Free tier allows 5 per minute, so two of those calls would be queued for the second minute. Still finishes in under two minutes total.&lt;/p&gt;

&lt;p&gt;For practical usage, the implementation handles this with a simple chunking loop: split URLs into groups of 30, send each chunk, and pause between chunks if a rate limit is hit. We'll see the specific code in the Implementation Walkthrough.&lt;/p&gt;

&lt;p&gt;But the headline is: rate limits matter only for the very first run on a large project, and even then they're a mild speed bump, not a real constraint.&lt;/p&gt;

&lt;h3&gt;
  
  
  What This Gives Us
&lt;/h3&gt;

&lt;p&gt;With the hash-file strategy layered on top of static keys and Purge API, the architecture now satisfies every requirement we set out in the first section:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Translation updates are a pure data operation.&lt;/strong&gt; No deploys, no version constants, no Wrangler variables. Just &lt;code&gt;i18n:migrate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache invalidation is explicit and external to the Worker bundle.&lt;/strong&gt; The Worker doesn't know or care about versions; it just reads from a stable URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalidation is granular.&lt;/strong&gt; Only the URLs that correspond to actually-changed content get purged. Everything else stays warm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hot path is one parallel cache lookup with zero KV reads.&lt;/strong&gt; Unchanged by the complexity of invalidation machinery - because invalidation happens out-of-band at deploy time, not in-band at request time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The conceptual work is done. What remains is making this work in actual code - how &lt;code&gt;fetcher.ts&lt;/code&gt;, &lt;code&gt;bundle-translations.ts&lt;/code&gt;, and &lt;code&gt;translations-keys.ts&lt;/code&gt; fit together, and the concrete patterns that make the whole thing maintainable.&lt;/p&gt;

&lt;p&gt;Let's walk through the implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Walkthrough
&lt;/h2&gt;

&lt;p&gt;The architecture we've built splits naturally across three files, each with a distinct responsibility. Let's look at how they fit together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;translations-keys.ts&lt;/code&gt;&lt;/strong&gt; is the single source of truth for addressing cache entries. It's imported by both the runtime fetcher and the migration script, so both sides of the invalidation contract speak the same URL format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;fetcher.ts&lt;/code&gt;&lt;/strong&gt; is the hot-path code that runs inside the Worker. It reads translations from the cache, falls back to KV when cache is cold, and writes results back to the cache for next time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;bundle-translations.ts&lt;/code&gt;&lt;/strong&gt; is the Node.js script that runs on your machine. It generates TypeScript artifacts, pushes translations to KV, detects which namespaces changed, and calls the Cloudflare Purge API with the exact URLs to invalidate.&lt;/p&gt;

&lt;p&gt;Three files. Each one has exactly one job. Most of the complexity of the whole invalidation system lives in the third file; the first two stay lean.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Keys Module
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;src/domain/i18n/translations-keys.ts&lt;/code&gt; exports three functions that together cover every way we need to address a translation entry:&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;// src/domain/i18n/translations-keys.ts&lt;/span&gt;

&lt;span class="c1"&gt;// KV key - used by env.TRANSLATIONS.get() inside the Worker&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;buildTranslationKvKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Namespace&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PROJECT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Cache URL - used as the key for the Workers Cache API&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;buildTranslationCacheUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Namespace&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`i18n:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PROJECT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheId&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Cache Request - what cache.match() and cache.put() actually take&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;buildTranslationCacheRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Namespace&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildTranslationCacheUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&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;These three functions are the glue that holds the whole architecture together. The fetcher uses &lt;code&gt;buildTranslationCacheRequest&lt;/code&gt; to look up and write entries. The migration script uses &lt;code&gt;buildTranslationCacheUrl&lt;/code&gt; to construct purge URLs. The KV access layer uses &lt;code&gt;buildTranslationKvKey&lt;/code&gt; to read from and write to KV. If any one of them drifted in format from the others, things would silently break - purge URLs would miss their targets, or cache reads would look for the wrong keys.&lt;/p&gt;

&lt;p&gt;Centralizing all three into one module enforces consistency at the type level. You can't accidentally build a cache URL one way in &lt;code&gt;fetcher.ts&lt;/code&gt; and another way in &lt;code&gt;bundle-translations.ts&lt;/code&gt;. There's only one place that knows the format.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fetcher
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;src/domain/i18n/fetcher.ts&lt;/code&gt; is where the hot-path logic lives. The function takes a locale and a list of namespaces, and returns the merged translation dictionaries. Under the hood, it does four things in sequence - though the first step runs in parallel across namespaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Check the cache for each namespace in parallel.&lt;/strong&gt;&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;// src/domain/i18n/fetcher.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheResults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;namespaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;cacheRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildTranslationCacheRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&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;cached&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;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheRequest&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;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`i18n cache READ error for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every namespace is checked independently. If you requested &lt;code&gt;common&lt;/code&gt;, &lt;code&gt;landing&lt;/code&gt;, and &lt;code&gt;newsletter&lt;/code&gt;, all three cache lookups happen at the same time. The &lt;code&gt;Promise.all&lt;/code&gt; wait is bounded by the slowest of the three - not their sum. Any individual lookup that throws (say the cache returned a malformed response) is caught per-namespace and demoted to a cache miss rather than failing the whole request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Separate hits from misses in a single pass.&lt;/strong&gt;&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;// src/domain/i18n/fetcher.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finalData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PickSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&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;missingNamespaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cacheResults&lt;/span&gt;&lt;span class="p"&gt;)&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;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;finalData&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;PickSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;missingNamespaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ns&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missingNamespaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// All namespaces served from cache - zero KV reads.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;finalData&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;PickSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the fast path. If every namespace was a cache hit, the function returns immediately with the merged result - we never touch KV, never allocate anything further. For a busy site with warm caches, the vast majority of requests take this path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Fetch missing namespaces from KV in a single batch.&lt;/strong&gt;&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;// src/domain/i18n/fetcher.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missingKvKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;missingNamespaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;buildTranslationKvKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;kvResults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;unknown&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;kvFailed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;kvResults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TRANSLATIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missingKvKeys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;unknown&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;kvFailed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare's KV batch &lt;code&gt;get()&lt;/code&gt; accepts up to 100 keys per call and returns a &lt;code&gt;Map&lt;/code&gt; keyed by the KV keys. One network round-trip, regardless of how many namespaces are missing. If KV is entirely unavailable - service outage, misconfigured binding, transient network issue - we catch the error into a &lt;code&gt;kvFailed&lt;/code&gt; flag instead of propagating it. The flag becomes the signal for the next step to use fallbacks only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Merge with fallbacks and schedule cache writes.&lt;/strong&gt;&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;// src/domain/i18n/fetcher.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;putPromises&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;missingNamespaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;ns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;missingNamespaces&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;N&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kvKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;missingKvKeys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kvValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kvFailed&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="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;kvResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kvKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;fallbackConstName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`FALLBACK_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FALLBACKS&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="nx"&gt;fallbackConstName&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;nsData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;deepMerge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kvValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;kvValue&lt;/span&gt;
  &lt;span class="nx"&gt;finalData&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nsData&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;PickSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ns&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;cacheRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildTranslationCacheRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nsData&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&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="s1"&gt;application/json; charset=utf-8&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="s1"&gt;Cache-Control&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="s1"&gt;public, s-maxage=31536000, immutable&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="nx"&gt;putPromises&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheRequest&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;clone&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;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`i18n cache WRITE error for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each missing namespace we construct the final value (KV result merged over the compiled fallback, or just the fallback if KV failed), write it into &lt;code&gt;finalData&lt;/code&gt; for the return value, and also enqueue a cache write for next time. The cache &lt;code&gt;put&lt;/code&gt; is wrapped in &lt;code&gt;.catch()&lt;/code&gt; - a failed write is logged and discarded, it doesn't break the request.&lt;/p&gt;

&lt;p&gt;Two things about the cache write itself. First, &lt;code&gt;Cache-Control: public, s-maxage=31536000, immutable&lt;/code&gt; - we tell the cache this entry lives for a year and never revalidates. Its only exit path is explicit purging. Second, &lt;code&gt;response.clone()&lt;/code&gt; is necessary because &lt;code&gt;cache.put&lt;/code&gt; takes ownership of the response body stream, and the stream can only be consumed once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Run the cache writes as background work.&lt;/strong&gt;&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;// src/domain/i18n/fetcher.ts&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;putPromises&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;allPuts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;putPromises&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;waitUntil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Real Workers: schedule as a non-blocking background task.&lt;/span&gt;
    &lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allPuts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Dev / non-Workers: await directly so cache is actually written.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;allPuts&lt;/span&gt;
  &lt;span class="p"&gt;}&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;finalData&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;PickSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, all cache writes are batched into one &lt;code&gt;waitUntil&lt;/code&gt; call so they happen in the background after the response has already been sent. The user doesn't wait for the cache write - the page renders immediately, and the cache entry lands shortly after.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;waitUntil&lt;/code&gt;-or-&lt;code&gt;await&lt;/code&gt; fallback matters for local development. Astro's dev server runs the fetcher in Node.js rather than inside a Cloudflare Worker, and there's no &lt;code&gt;waitUntil&lt;/code&gt; there. If we blindly scheduled the writes via &lt;code&gt;waitUntil?.()&lt;/code&gt; and it was undefined, the writes would never run, and the cache would stay empty. Falling back to &lt;code&gt;await&lt;/code&gt; keeps the code testable locally - cache writes are synchronous in dev but non-blocking in prod.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Migration Script
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;scripts/bundle-translations.ts&lt;/code&gt; is the longer and more interesting file. It does a lot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parses command-line flags (&lt;code&gt;--fallbacks&lt;/code&gt;, &lt;code&gt;--local&lt;/code&gt;, &lt;code&gt;--deploy-version&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Reads every JSON file under &lt;code&gt;./locales/**&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Computes per-namespace content hashes and detects changes against &lt;code&gt;.i18n-hashes.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Generates four artifacts: &lt;code&gt;i18n-data.json&lt;/code&gt;, &lt;code&gt;i18n.generated.d.ts&lt;/code&gt;, &lt;code&gt;runtime-constants.ts&lt;/code&gt;, and optionally &lt;code&gt;fallbacks.generated.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pushes translations to KV (local or remote depending on the &lt;code&gt;--local&lt;/code&gt; flag).&lt;/li&gt;
&lt;li&gt;Builds Purge URLs for changed namespaces only.&lt;/li&gt;
&lt;li&gt;Calls the Cloudflare Purge API with those URLs, chunking as needed.&lt;/li&gt;
&lt;li&gt;Updates &lt;code&gt;.i18n-hashes.json&lt;/code&gt; - but only if the purge step actually succeeded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'll walk through the parts that matter for this article's architecture - hash comparison, purge call, and the graceful recovery logic. The other parts (JSON reading, TS codegen) are mechanical and already described in Part 1.&lt;/p&gt;

&lt;h4&gt;
  
  
  Loading &lt;code&gt;.dev.vars&lt;/code&gt; Manually
&lt;/h4&gt;

&lt;p&gt;One small but important detail: the script needs access to &lt;code&gt;CLOUDFLARE_CACHEPURGE_API_TOKEN&lt;/code&gt;, which lives in &lt;code&gt;.dev.vars&lt;/code&gt;. &lt;code&gt;wrangler dev&lt;/code&gt; reads that file automatically at runtime, but &lt;code&gt;tsx&lt;/code&gt; (which is what runs the migration script) doesn't. So we parse it ourselves, once, right before the purge step:&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;// scripts/bundle-translations.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;devVarsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.dev.vars&lt;/span&gt;&lt;span class="dl"&gt;'&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devVarsPath&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;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devVarsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&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;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eqIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&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;eqIndex&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eqIndex&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eqIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;key&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;!(key in process.env)&lt;/code&gt; guard is important - if a variable is already set in the real environment (say by an explicit shell export), we don't overwrite it. Real environment wins; &lt;code&gt;.dev.vars&lt;/code&gt; fills the gaps.&lt;/p&gt;

&lt;p&gt;This is the kind of detail that's easy to miss until you spend an hour debugging why your script can't find a token that's definitely in the right file.&lt;/p&gt;

&lt;h4&gt;
  
  
  Computing Hashes and Diffing
&lt;/h4&gt;

&lt;p&gt;After reading all JSON files, the script computes current hashes and compares against the previous run in a single pass:&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;// scripts/bundle-translations.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;previousHashes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HashMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HASHES_FILE&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HASHES_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HashMap&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentHashes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HashMap&lt;/span&gt; &lt;span class="o"&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;changedKeys&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="c1"&gt;// "&amp;lt;locale&amp;gt;:&amp;lt;namespace&amp;gt;" pairs that changed&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;namespaces&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collected&lt;/span&gt;&lt;span class="p"&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;namespaces&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;hashKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;currentHashes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hashKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hash&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;previousHashes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hashKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;changedKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hashKey&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;changedKeys&lt;/code&gt; is a flat array of strings in the form &lt;code&gt;"&amp;lt;locale&amp;gt;:&amp;lt;namespace&amp;gt;"&lt;/code&gt;. Missing file on disk → empty &lt;code&gt;previousHashes&lt;/code&gt; → every current key is "changed" → all URLs get purged on first run. This is the correct safe default I described in the last section.&lt;/p&gt;

&lt;p&gt;Two implementation details worth calling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;stableStringify&lt;/code&gt; instead of &lt;code&gt;JSON.stringify&lt;/code&gt;.&lt;/strong&gt; Regular &lt;code&gt;JSON.stringify&lt;/code&gt; preserves key order from the source object. That's fine if your JSON files never have their keys reordered, but it's fragile - a prettier version bump or a text editor that alphabetizes keys on save would produce different hashes for identical content. &lt;code&gt;stableStringify&lt;/code&gt; sorts keys deterministically before serializing, so the hash reflects &lt;em&gt;content&lt;/em&gt;, not &lt;em&gt;key order&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Truncated hash (12 chars).&lt;/strong&gt; SHA-256 produces 64 hex characters. We only need enough bits to detect collisions between a few hundred namespace contents, and 12 hex chars is plenty - that's 48 bits of entropy, far more than needed for this use case. Shorter hashes make logs and debugging output more readable.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Purge API Call
&lt;/h4&gt;

&lt;p&gt;Once we have &lt;code&gt;changedKeys&lt;/code&gt;, we build the full list of URLs to purge:&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;// scripts/bundle-translations.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;purgeUrls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;changedKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;hashKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hashKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;buildTranslationCacheUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&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;ns&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where &lt;code&gt;translations-keys.ts&lt;/code&gt; pays off most visibly. The same function that the fetcher uses to construct cache keys for &lt;code&gt;cache.put&lt;/code&gt; and &lt;code&gt;cache.match&lt;/code&gt; is now producing the list of URLs to delete. There's no second "how to construct a purge URL" function anywhere - just one formula, used in three different places.&lt;/p&gt;

&lt;p&gt;Now the chunking and rate-limit handling. Cloudflare's Purge API accepts up to &lt;strong&gt;100 URLs per single request&lt;/strong&gt; (this is the same limit across all plans), and the Free plan allows &lt;strong&gt;800 URLs per second&lt;/strong&gt; total. So we chunk into groups of 100 and throttle to 8 chunks per second at most:&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;// scripts/bundle-translations.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHUNK_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_CHUNKS_PER_SEC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;CHUNK_SIZE&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;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CHUNK_SIZE&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;currentChunkIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;CHUNK_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.cloudflare.com/client/v4/zones/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;zoneId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/purge_cache`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&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="s1"&gt;application/json&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chunk&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;if &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="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Rate limit hit - wait and retry the same chunk.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;CHUNK_SIZE&lt;/span&gt;
    &lt;span class="k"&gt;continue&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="o"&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;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="c1"&gt;// propagate failure to caller&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// After 8 chunks (= 800 URLs), pause a second to stay under the cap.&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;currentChunkIndex&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;MAX_CHUNKS_PER_SEC&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CHUNK_SIZE&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&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;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the vast majority of real-world uses - a handful of URLs per migration - this loop runs once and exits. The retry logic and the pacing pause only matter on a fresh setup where hundreds of URLs need purging at once.&lt;/p&gt;

&lt;h4&gt;
  
  
  Updating the Hash File (Carefully)
&lt;/h4&gt;

&lt;p&gt;Here's the part that makes the system recover gracefully from partial failures. The hash file is updated only in outcomes where invalidation was either successful or not needed:&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;// scripts/bundle-translations.ts&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;shouldWriteHashes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;IS_LOCAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Local: no edge cache exists, purge is not applicable.&lt;/span&gt;
  &lt;span class="nx"&gt;shouldWriteHashes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;changedKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Remote with no changes: nothing needed purging.&lt;/span&gt;
  &lt;span class="nx"&gt;shouldWriteHashes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Remote with changes: purge must succeed to commit the new state.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;zoneId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apiToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[i18n] Skipping purge - credentials missing.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// shouldWriteHashes stays false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;purgeSuccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;purgeTranslationsCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;zoneId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;apiToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;purgeUrls&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;purgeSuccess&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;shouldWriteHashes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[i18n] Purge failed - hash file NOT updated.&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="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;shouldWriteHashes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;HASHES_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentHashes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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="s1"&gt;utf8&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four outcomes, only three of which commit the new state to disk. The fourth outcome - remote run with changes and a failed purge - deliberately leaves the hash file untouched. The next migration will see the same diff as this one and re-attempt the invalidation automatically. No manual intervention, no silent staleness, no wedged state.&lt;/p&gt;

&lt;p&gt;This is the "graceful recovery" pattern from the previous section made concrete. A successful migration updates the ledger. A failed migration leaves the ledger where it was, so the next attempt gets a free retry on exactly the same set of URLs.&lt;/p&gt;

&lt;h3&gt;
  
  
  End-to-End Flow: From Migration to Edge Cache
&lt;/h3&gt;

&lt;p&gt;Here's the full picture of how the three files cooperate during a translation update:&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="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;edit&lt;/span&gt; &lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;landing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;
       &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="nx"&gt;npm&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;migrate&lt;/span&gt;
              &lt;span class="err"&gt;│&lt;/span&gt;
              &lt;span class="err"&gt;▼&lt;/span&gt;
&lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;translations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
   &lt;span class="err"&gt;│&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="nx"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;codegen&lt;/span&gt; &lt;span class="nx"&gt;artifacts&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;stableStringify&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;sha256&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;currentHashes&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;hashes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;previousHashes&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;changedKeys&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;en:landing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;push&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;KV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wrangler&lt;/span&gt; &lt;span class="nx"&gt;kv&lt;/span&gt; &lt;span class="nx"&gt;bulk&lt;/span&gt; &lt;span class="nx"&gt;put&lt;/span&gt; &lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;remote&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;load&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vars&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;imports&lt;/span&gt; &lt;span class="nx"&gt;translations&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nf"&gt;buildTranslationCacheUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&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="s1"&gt;landing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="err"&gt;│&lt;/span&gt;     &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://edgekits.dev/i18n%3Aen%3Alanding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;Cloudflare&lt;/span&gt; &lt;span class="nx"&gt;Purge&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;purgeUrl&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;purge&lt;/span&gt; &lt;span class="nx"&gt;succeeded&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;write&lt;/span&gt; &lt;span class="nx"&gt;currentHashes&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;hashes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;
   &lt;span class="err"&gt;└──&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt; &lt;span class="nx"&gt;purged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;other&lt;/span&gt; &lt;span class="nx"&gt;namespaces&lt;/span&gt; &lt;span class="nx"&gt;remain&lt;/span&gt; &lt;span class="nx"&gt;warm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="nx"&gt;Next&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="sr"&gt;/en/&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
       &lt;span class="err"&gt;│&lt;/span&gt;
       &lt;span class="err"&gt;▼&lt;/span&gt;
&lt;span class="nx"&gt;fetcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ts &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inside&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
   &lt;span class="err"&gt;│&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;imports&lt;/span&gt; &lt;span class="nx"&gt;translations&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nf"&gt;buildTranslationCacheRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&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="s1"&gt;landing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="err"&gt;│&lt;/span&gt;     &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;same&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;above&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nc"&gt;MISS &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;we&lt;/span&gt; &lt;span class="nx"&gt;just&lt;/span&gt; &lt;span class="nx"&gt;purged&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;KV&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ns&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="s1"&gt;landing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
   &lt;span class="err"&gt;│&lt;/span&gt;     &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;returns&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;merge&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_LANDING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;write&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;finalData&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;freshResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;via&lt;/span&gt; &lt;span class="nx"&gt;waitUntil&lt;/span&gt;
   &lt;span class="err"&gt;└──&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="nx"&gt;translations&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;

&lt;span class="nx"&gt;Every&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="nx"&gt;that&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
       &lt;span class="err"&gt;│&lt;/span&gt;
       &lt;span class="err"&gt;▼&lt;/span&gt;
&lt;span class="nx"&gt;fetcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;HIT&lt;/span&gt;
   &lt;span class="err"&gt;├──&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="nx"&gt;translations&lt;/span&gt;
   &lt;span class="err"&gt;└──&lt;/span&gt; &lt;span class="nx"&gt;zero&lt;/span&gt; &lt;span class="nx"&gt;KV&lt;/span&gt; &lt;span class="nx"&gt;reads&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The invalidation happens at migration time, out of band. Requests are never slowed down by the invalidation machinery - they only benefit from the cache it produces. And because &lt;code&gt;translations-keys.ts&lt;/code&gt; is shared between the Worker and the migration script, the URLs that get purged are guaranteed to be the same URLs the fetcher writes and reads. No drift. No silently-missed invalidations.&lt;/p&gt;

&lt;p&gt;This is the design in its entirety. Everything else is configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Setup &amp;amp; DX Considerations
&lt;/h2&gt;

&lt;p&gt;The code is the easy part. The awkward part, which I ended up rewriting notes about three times, is the order in which you have to set up the pieces on the Cloudflare side to make the first migration actually work.&lt;/p&gt;

&lt;p&gt;The pieces that need to exist before &lt;code&gt;npm run i18n:migrate&lt;/code&gt; runs are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real Cloudflare KV namespace with a real ID in &lt;code&gt;wrangler.jsonc&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A deployed Worker attached to your custom domain (proxied through Cloudflare).&lt;/li&gt;
&lt;li&gt;An API token with &lt;code&gt;Cache Purge&lt;/code&gt; permission, accessible to the script via &lt;code&gt;.dev.vars&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The zone ID of your Cloudflare-managed domain, readable from &lt;code&gt;wrangler.jsonc&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;That same API token registered as a Worker Secret on Cloudflare, for any production runtime code that might want it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is complicated individually. But the ordering matters, because several of the steps depend on outputs from earlier steps. Here's the sequence that works, in the order I wish someone had handed me.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Create the KV Namespace
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler kv namespace create TRANSLATIONS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrangler prints the new namespace's ID. Copy it into &lt;code&gt;wrangler.jsonc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"kv_namespaces"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TRANSLATIONS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;your-real-kv-id&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the absence of &lt;code&gt;preview_id&lt;/code&gt;. The Cloudflare docs are clear that &lt;code&gt;preview_id&lt;/code&gt; is only required when using &lt;code&gt;wrangler dev --remote&lt;/code&gt; to develop against remote resources - which this project doesn't. For local development, Wrangler uses &lt;code&gt;id&lt;/code&gt; as a folder name inside &lt;code&gt;.wrangler/state/v3/kv/&lt;/code&gt; and never validates the format. So the same field works for both local dev (where the value is just a folder name) and remote deploys (where it resolves to an actual KV namespace).&lt;/p&gt;

&lt;p&gt;Incidentally, this means that before you've created a real namespace, &lt;code&gt;wrangler.jsonc&lt;/code&gt; can contain a placeholder like &lt;code&gt;"id": "your_kv_id_here"&lt;/code&gt; and local dev still works. &lt;code&gt;npm run dev&lt;/code&gt; in this starter runs Astro's Node.js dev server - it doesn't call Wrangler at all, so there's no authentication step involved there. &lt;code&gt;npm run i18n:seed&lt;/code&gt; does invoke &lt;code&gt;wrangler kv bulk put --local&lt;/code&gt;, which writes to a local folder under &lt;code&gt;.wrangler/state/&lt;/code&gt; without hitting any remote API, but depending on your Wrangler version and any previously-cached credentials, it may still try to verify your account. If that prompt appears on a first-time setup, &lt;code&gt;wrangler logout&lt;/code&gt; or clearing &lt;code&gt;~/.config/.wrangler/&lt;/code&gt; is usually enough to reset it into a true no-account state.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Add the Zone ID to &lt;code&gt;wrangler.jsonc&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"vars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"CLOUDFLARE_ZONE_ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;your-zone-id&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"I18N_CACHE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"on"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"DEBUG_I18N"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"DEMO_MODE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The zone ID is visible on the Overview page of your Cloudflare domain dashboard, in the right sidebar. It's not a secret - it's just an identifier for your zone, similar to an account number. Committing it to a public repository is fine.&lt;/p&gt;

&lt;p&gt;Types for the &lt;code&gt;Env&lt;/code&gt; interface regenerate automatically the next time you run &lt;code&gt;npm run dev&lt;/code&gt; (the starter's &lt;code&gt;dev&lt;/code&gt; script runs &lt;code&gt;wrangler types&lt;/code&gt; before Astro starts). If you want to pick up the new variable immediately in your IDE without restarting the dev server - say you're editing runtime code that references &lt;code&gt;env.CLOUDFLARE_ZONE_ID&lt;/code&gt; - run &lt;code&gt;npm run typegen&lt;/code&gt; explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Create the API Token
&lt;/h3&gt;

&lt;p&gt;Go to &lt;a href="https://dash.cloudflare.com/profile/api-tokens" rel="noopener noreferrer"&gt;Cloudflare API Tokens&lt;/a&gt; and click &lt;strong&gt;Create Token&lt;/strong&gt;. Either use the &lt;strong&gt;Cache Purge&lt;/strong&gt; template directly, or create a custom token with the permission: &lt;code&gt;Zone → Cache Purge → Purge&lt;/code&gt;. Scope the token to your specific zone, not "all zones."&lt;/p&gt;

&lt;p&gt;Once the token is created, paste it into &lt;code&gt;.dev.vars&lt;/code&gt; in your project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CLOUDFLARE_CACHEPURGE_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your-token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.dev.vars&lt;/code&gt; is gitignored by default in this starter - verify it is in yours before pasting anything. A leaked Cache Purge token has a narrow blast radius (worst case: an attacker can briefly invalidate your cache), but leaking any token is still bad hygiene.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Deploy the Worker
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Worker has to exist in Cloudflare's infrastructure before the first &lt;code&gt;i18n:migrate&lt;/code&gt; can run. The migration script doesn't deploy the Worker itself - it only pushes data and purges cache. If there's no Worker there, there's nothing to purge from.&lt;/p&gt;

&lt;p&gt;If your custom domain is already configured as a Worker route or custom domain binding, verify it's proxied (orange cloud in DNS). Without proxying there's no Cloudflare CDN layer in front of your Worker - the Cache API won't have anywhere to store entries, and the Purge API won't have anywhere to purge from. The migration script will still run, reporting successful KV updates and successful purge requests, but none of the caching behavior this architecture relies on will actually happen.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Register the Token as a Worker Secret
&lt;/h3&gt;

&lt;p&gt;Even though the migration script doesn't need this, registering the same token as a Worker Secret means production runtime code can access it if you ever add a feature that needs to purge cache from inside a Worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler secret put CLOUDFLARE_CACHEPURGE_API_TOKEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, equivalently, through the Cloudflare dashboard: &lt;strong&gt;Worker → Settings → Variables and Secrets → Add&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This step is optional if you're sure you'll only ever purge cache from the migration script. But adding it now is free, and it means future-you has one less thing to debug when a webhook handler wants to invalidate a specific translation on content-change events from a CMS.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Run Your First Migration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run i18n:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the very first run, &lt;code&gt;.i18n-hashes.json&lt;/code&gt; doesn't exist yet. The script treats every namespace as changed, pushes all translations to KV, and issues purge requests for every URL. The next request to your site populates the cache fresh. From this point on, every subsequent migration purges only the namespaces that actually changed.&lt;/p&gt;

&lt;p&gt;If something goes wrong - missing credentials, network error, rate limit - the graceful recovery logic from the last section kicks in. KV will be updated (that part succeeds first), but the hash file will stay in its old state, so the next migration re-attempts the invalidation automatically. You just re-run &lt;code&gt;i18n:migrate&lt;/code&gt; after fixing whatever was wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  DX Considerations for Translation Workflows
&lt;/h3&gt;

&lt;p&gt;Beyond the setup checklist, there are a few ergonomic decisions baked into the implementation worth mentioning. None of them are architectural, but they add up to the difference between a starter you want to use and one that feels like homework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Placeholder KV IDs work out of the box.&lt;/strong&gt; A new developer cloning the repo can start working without creating a Cloudflare account first. &lt;code&gt;wrangler.jsonc&lt;/code&gt; ships with &lt;code&gt;"id": "your_kv_id_here"&lt;/code&gt;, which Wrangler treats as a local folder name under &lt;code&gt;.wrangler/state/&lt;/code&gt;. &lt;code&gt;npm run dev&lt;/code&gt; uses Astro's Node.js dev server and doesn't touch Wrangler at all; &lt;code&gt;npm run i18n:seed&lt;/code&gt; writes to that local folder via &lt;code&gt;wrangler kv bulk put --local&lt;/code&gt;. Neither command needs a real KV namespace ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three commands, each doing one thing.&lt;/strong&gt; The package.json exposes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"i18n:bundle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"tsx scripts/bundle-translations.ts"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"i18n:seed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;"tsx scripts/bundle-translations.ts --deploy-version --local"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"i18n:migrate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsx scripts/bundle-translations.ts --deploy-version"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same script, three different modes via flags. &lt;code&gt;bundle&lt;/code&gt; generates artifacts only (useful for CI type checking). &lt;code&gt;seed&lt;/code&gt; pushes to local KV. &lt;code&gt;migrate&lt;/code&gt; pushes to remote KV and purges cache. No one has to remember which flag does what - the command names tell you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--fallbacks&lt;/code&gt; as an opt-in.&lt;/strong&gt; The compiled fallback dictionaries aren't generated by default because they add build time and bundle size, and most projects don't need the extra runtime safety net if KV is reliable. Append &lt;code&gt;--fallbacks&lt;/code&gt; to any of the three commands to enable them, or set &lt;code&gt;I18N_GENERATE_FALLBACKS=true&lt;/code&gt; in &lt;code&gt;.dev.vars&lt;/code&gt; to always generate them. The fetcher checks for the generated file at runtime and uses it if present, so switching fallbacks on or off doesn't require any code changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gitignore discipline.&lt;/strong&gt; The starter's &lt;code&gt;.gitignore&lt;/code&gt; excludes &lt;code&gt;.dev.vars&lt;/code&gt;, &lt;code&gt;i18n-data.json&lt;/code&gt;, &lt;code&gt;src/i18n.generated.d.ts&lt;/code&gt;, and &lt;code&gt;.i18n-hashes.json&lt;/code&gt;. All four are machine-local state or generated artifacts - committing them causes merge conflicts, accidental secret leaks, or stale type definitions in CI. The README documents this explicitly so new contributors don't "fix" it by removing entries they think are missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-fatal missing credentials.&lt;/strong&gt; If &lt;code&gt;CLOUDFLARE_ZONE_ID&lt;/code&gt; or &lt;code&gt;CLOUDFLARE_CACHEPURGE_API_TOKEN&lt;/code&gt; are missing, the script logs a warning and continues without attempting the purge step. This is deliberate - sometimes you want to push translations without also invalidating (say, seeding a fresh namespace that doesn't have cache entries yet). The graceful recovery pattern means skipping purge doesn't corrupt state; it just means the hash file stays where it was and the next migration retries.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Full Setup Gives You
&lt;/h3&gt;

&lt;p&gt;If you've followed the sequence above, you now have an operational setup where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A non-developer on your team can update any JSON file in &lt;code&gt;./locales/**&lt;/code&gt; and trigger a migration themselves, without going near the deploy pipeline.&lt;/li&gt;
&lt;li&gt;That migration pushes new content globally in about a second - the time it takes Cloudflare's Purge API to propagate.&lt;/li&gt;
&lt;li&gt;Only the namespaces that actually changed get invalidated; everything else stays warm in cache.&lt;/li&gt;
&lt;li&gt;The Worker itself never gets redeployed. It just keeps running, serving increasingly well-cached translations, and reading from KV only when content genuinely changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fisehyl54a5u4s6nzm4nd.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fisehyl54a5u4s6nzm4nd.jpg" alt="Decoupled content workflow for edge-native i18n: three-step command progression from npm run i18n:bundle for local artifacts, through i18n:seed for local KV seeding, to i18n:migrate for production KV push and instant cache purge - all without Wrangler deploy access." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is what decoupling translations from code deployments actually looks like in practice. It's not a single clever trick - it's a small constellation of platform features (KV, Cache API, Purge API, Worker Secrets, &lt;code&gt;wrangler.jsonc&lt;/code&gt; vars) composed in a specific order so that each one carries exactly the weight it's designed for.&lt;/p&gt;

&lt;p&gt;What I want to show next is what this costs and what it returns, in concrete numbers. It's one thing to say "zero KV reads on the hot path"; it's another to look at an actual production log and watch the arithmetic hold up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance in Production: Real wrangler tail Logs
&lt;/h2&gt;

&lt;p&gt;A good architecture survives contact with a production log. It's one thing to claim "zero KV reads on the hot path"; it's another to watch it happen, line by line, on a real deployment serving real traffic.&lt;/p&gt;

&lt;p&gt;Let me walk through actual &lt;code&gt;wrangler tail&lt;/code&gt; output from edgekits.dev - not cherry-picked best cases, just consecutive requests captured during normal use - and trace what each one cost in KV reads, cache lookups, and purge operations.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkpl9zqj9ow9h6i8p8sd4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkpl9zqj9ow9h6i8p8sd4.jpg" alt="Real wrangler tail telemetry from edgekits.dev production deployment showing FULL HIT on all three requested namespaces (common, landing, newsletter) with total KV reads equal to zero and sub-millisecond latency on the Cloudflare Workers hot path." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Steady-State Hot Path: Zero KV Reads
&lt;/h3&gt;

&lt;p&gt;Here's what a warm cache looks like. Multiple users hitting different pages, all locales, all namespaces already populated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET https://edgekits.dev/en/legal/refund-policy/ - Ok @ 18:42:04
  (log) i18n cache FULL HIT { locale: 'en', namespaces: [ 'legal' ] }
  (log) i18n cache FULL HIT { locale: 'en', namespaces: [ 'landing' ] }
  (log) i18n cache FULL HIT { locale: 'en', namespaces: [ 'landing' ] }

GET https://edgekits.dev/de/legal/refund-policy/ - Ok @ 18:42:12
  (log) i18n cache FULL HIT { locale: 'de', namespaces: [ 'legal' ] }
  (log) i18n cache FULL HIT { locale: 'de', namespaces: [ 'landing' ] }
  (log) i18n cache FULL HIT { locale: 'de', namespaces: [ 'landing' ] }

GET https://edgekits.dev/de/legal/terms/ - Ok @ 18:42:16
  (log) i18n cache FULL HIT { locale: 'de', namespaces: [ 'legal' ] }
  (log) i18n cache FULL HIT { locale: 'de', namespaces: [ 'landing' ] }
  (log) i18n cache FULL HIT { locale: 'de', namespaces: [ 'landing' ] }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line starts with &lt;code&gt;FULL HIT&lt;/code&gt;. No &lt;code&gt;KV batch&lt;/code&gt;, no &lt;code&gt;cache PUT&lt;/code&gt;. Three &lt;code&gt;fetchTranslations&lt;/code&gt; calls per page - one in the layout for &lt;code&gt;legal&lt;/code&gt; (legal pages pull a shared legal-copy namespace), two in subcomponents for &lt;code&gt;landing&lt;/code&gt; (header and footer use &lt;code&gt;landing&lt;/code&gt; namespace) - and every single one of them is served directly from the edge cache.&lt;/p&gt;

&lt;p&gt;Total KV reads for this entire three-request sequence: &lt;strong&gt;zero&lt;/strong&gt;. Each page is assembled from cache entries that were warmed minutes or hours earlier and haven't been invalidated since. This is the default cost of serving a request in steady state.&lt;/p&gt;

&lt;h3&gt;
  
  
  First-Touch Cache Warming Per Edge Node
&lt;/h3&gt;

&lt;p&gt;When a request hits an edge node that hasn't served the requested locale before, the cache is cold for that locale. Here's what that looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET https://edgekits.dev/es/legal/refund-policy/ - Ok @ 18:42:31
  (log) i18n cache PARTIAL/FULL MISS { locale: 'es', hit: 0, miss: 1 }
  (log) i18n KV batch OK { locale: 'es', missingNamespaces: [ 'legal' ] }
  (log) i18n cache PUT scheduled { locale: 'es', missingNamespaces: [ 'legal' ] }
  (log) i18n cache PARTIAL/FULL MISS { locale: 'es', hit: 0, miss: 1 }
  (log) i18n cache PARTIAL/FULL MISS { locale: 'es', hit: 0, miss: 1 }
  (log) i18n KV batch OK { locale: 'es', missingNamespaces: [ 'landing' ] }
  (log) i18n cache PUT scheduled { locale: 'es', missingNamespaces: [ 'landing' ] }
  (log) i18n KV batch OK { locale: 'es', missingNamespaces: [ 'landing' ] }
  (log) i18n cache PUT scheduled { locale: 'es', missingNamespaces: [ 'landing' ] }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the first Spanish request on this edge node, and there's a detail worth noticing. The &lt;code&gt;landing&lt;/code&gt; namespace shows three &lt;code&gt;PARTIAL MISS&lt;/code&gt; and three &lt;code&gt;KV batch OK&lt;/code&gt; entries - not one. Why?&lt;/p&gt;

&lt;p&gt;Because this particular page has three components that independently call &lt;code&gt;fetchTranslations(runtime, 'es', ['landing'])&lt;/code&gt;: the header, a featured content block, and the footer. They all execute in parallel inside the same Astro SSR pass. All three check the cache simultaneously and all three miss simultaneously - because the cache hasn't been populated yet. All three then fetch from KV in parallel.&lt;/p&gt;

&lt;p&gt;This is a one-time cost. Look at the very next Spanish request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET https://edgekits.dev/es/legal/delivery-policy/ - Ok @ 18:43:01
  (log) i18n cache FULL HIT { locale: 'es', namespaces: [ 'legal' ] }
  (log) i18n cache FULL HIT { locale: 'es', namespaces: [ 'landing' ] }
  (log) i18n cache FULL HIT { locale: 'es', namespaces: [ 'landing' ] }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three &lt;code&gt;FULL HIT&lt;/code&gt;. The parallel misses warmed the cache with three concurrent &lt;code&gt;cache.put&lt;/code&gt; calls - the last one wins, they all wrote the same content anyway. From this request onward, every Spanish-locale page gets a free ride from cache until an explicit purge invalidates an entry.&lt;/p&gt;

&lt;p&gt;Could we eliminate that parallel-miss cost? In principle, yes - a request-scoped memoization layer in &lt;code&gt;Astro.locals&lt;/code&gt; would ensure only one component out of the three actually hits KV, and the others wait on its promise. But in practice this optimization doesn't earn its complexity.&lt;/p&gt;

&lt;p&gt;The parallel miss happens &lt;strong&gt;once per locale per edge node&lt;/strong&gt;, ever, until the cache is explicitly invalidated. Three KV reads at warmup time, in exchange for no request-scoped state to maintain, no additional abstraction between components and the fetcher. I chose to leave it as-is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mixed Traffic: Partial Cache Hits + KV Batch Fallback
&lt;/h3&gt;

&lt;p&gt;Real traffic rarely hits the extremes of "all cold" or "all warm." Here's a more realistic mix - users bouncing between locales, where some namespaces are cached and others aren't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET https://edgekits.dev/es/ - Ok @ 18:45:13
  (log) i18n cache PARTIAL/FULL MISS { locale: 'es', hit: 1, miss: 3 }
  (log) i18n KV batch OK {
  locale: 'es',
  missingNamespaces: [ 'common', 'newsletter', 'messages' ]
}
  (log) i18n cache PUT scheduled {
  locale: 'es',
  missingNamespaces: [ 'common', 'newsletter', 'messages' ]
}
  (log) i18n cache FULL HIT { locale: 'es', namespaces: [ 'landing' ] }
  (log) i18n cache FULL HIT { locale: 'es', namespaces: [ 'landing' ] }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The homepage requested &lt;code&gt;common&lt;/code&gt;, &lt;code&gt;landing&lt;/code&gt;, &lt;code&gt;newsletter&lt;/code&gt;, &lt;code&gt;messages&lt;/code&gt; - four namespaces total. One of them (&lt;code&gt;landing&lt;/code&gt;) is already cached from that earlier legal-page request; three aren't. The fetcher does exactly what you'd hope: &lt;code&gt;PARTIAL MISS { hit: 1, miss: 3 }&lt;/code&gt;, one KV batch call for the three missing ones, one combined &lt;code&gt;cache PUT&lt;/code&gt; for all three.&lt;/p&gt;

&lt;p&gt;Note the KV batch. &lt;code&gt;missingNamespaces: [ 'common', 'newsletter', 'messages' ]&lt;/code&gt; - three keys, one round-trip. This is why step 3 of the fetcher uses &lt;code&gt;env.TRANSLATIONS.get(missingKvKeys, ...)&lt;/code&gt; with an array argument instead of calling &lt;code&gt;.get()&lt;/code&gt; individually. Even with four namespaces, we never do more than one KV round-trip per page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Granular Invalidation in Action: One URL Purged
&lt;/h3&gt;

&lt;p&gt;Now the payoff. What happens when translations actually change?&lt;/p&gt;

&lt;p&gt;This is from the migration script's console output after editing a single string in &lt;code&gt;en/landing.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm run i18n:migrate
&lt;span class="go"&gt;[i18n] Changed namespaces (1):
  - en:landing
[i18n] Pushing translations to remote KV...
[i18n] ✅ KV updated (remote).
[i18n] Purging 1 cache entries via Cloudflare API...
[i18n] Purging cache for 1 URL(s)...
[i18n] [Chunk 1] Purged 1 URL(s).
[i18n] ✅ Cache purge completed.
[i18n] ✅ .i18n-hashes.json updated.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One URL. That's it. &lt;code&gt;en:landing&lt;/code&gt; was the only pair whose content hash differed from the previous run, so only &lt;code&gt;https://edgekits.dev/i18n%3Aen%3Alanding&lt;/code&gt; got invalidated. Every other &lt;code&gt;locale:namespace&lt;/code&gt; combination - &lt;code&gt;en:common&lt;/code&gt;, &lt;code&gt;en:blog&lt;/code&gt;, &lt;code&gt;de:landing&lt;/code&gt;, &lt;code&gt;es:landing&lt;/code&gt;, &lt;code&gt;ja:landing&lt;/code&gt;, and so on - remained warm in the cache everywhere in the world.&lt;/p&gt;

&lt;p&gt;Immediately after that, the first user to hit the English landing page triggered a cold-cache response for exactly one namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET https://edgekits.dev/en/ - Ok @ 18:56:53
  (log) i18n cache PARTIAL/FULL MISS { locale: 'en', hit: 3, miss: 1 }
  (log) i18n KV batch OK { locale: 'en', missingNamespaces: [ 'landing' ] }
  (log) i18n cache PUT scheduled { locale: 'en', missingNamespaces: [ 'landing' ] }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hit: 3, miss: 1&lt;/code&gt;. Three of the four namespaces on this page - &lt;code&gt;common&lt;/code&gt;, &lt;code&gt;newsletter&lt;/code&gt;, &lt;code&gt;messages&lt;/code&gt; - were still in cache, untouched by the migration. Only &lt;code&gt;landing&lt;/code&gt; was missing, and only &lt;code&gt;landing&lt;/code&gt; was re-fetched from KV. The re-fetch pulled the updated content, wrote it back to cache, and the next request saw the new text.&lt;/p&gt;

&lt;p&gt;From the second request onward, everything was warm again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET https://edgekits.dev/en/ - Ok @ 18:57:51
  (log) i18n cache FULL HIT {
  locale: 'en',
  namespaces: [ 'common', 'landing', 'newsletter', 'messages' ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user who fixed the Spanish typo doesn't trigger cache invalidation for German users. Fixing German doesn't touch English. Fixing landing doesn't touch blog. This is what "granular" means in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  KV Reads and Purge Costs, by Operation
&lt;/h3&gt;

&lt;p&gt;Let me total up the operational costs for a realistic workload. Assume a project with 5 locales and 10 namespaces (50 total &lt;code&gt;locale:namespace&lt;/code&gt; pairs), traffic spread across a few dozen edge nodes, and translation updates happening a few times a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serving requests (per edge node, steady state):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero KV reads per request. Bounded only by parallel cache lookups per namespace.&lt;/li&gt;
&lt;li&gt;Per page: typically 3–4 parallel &lt;code&gt;cache.match&lt;/code&gt; calls, all hitting local edge cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;First request per locale per edge node (after a deploy or eviction):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One KV batch call with up to N keys, where N = number of missing namespaces (typically 3–5).&lt;/li&gt;
&lt;li&gt;Parallel &lt;code&gt;cache.put&lt;/code&gt; calls via &lt;code&gt;waitUntil&lt;/code&gt; - doesn't block the response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Translation migration (per &lt;code&gt;i18n:migrate&lt;/code&gt; run):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One &lt;code&gt;wrangler kv bulk put&lt;/code&gt; pushing all 50 key/value pairs to remote KV.&lt;/li&gt;
&lt;li&gt;One Cloudflare Purge API call with 1–5 URLs (matching the number of namespaces that actually changed).&lt;/li&gt;
&lt;li&gt;One local disk write for &lt;code&gt;.i18n-hashes.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;First request per locale per edge node after a migration that touched that namespace:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Same as first-touch warming, but scoped only to the invalidated namespace. Other namespaces stay cached.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Across a month of this workload, the KV read budget spent on actually-used data is dominated by first-touch warming - a few thousand reads total, depending on how many distinct edge nodes see traffic. Purge API calls total maybe 20–40 for the entire month. Nothing even approaches the free-tier ceiling on either metric.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Changed Versus Part 1
&lt;/h3&gt;

&lt;p&gt;For completeness, here's what the same workload cost under the old architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serving requests:&lt;/strong&gt; same, zero KV reads on cache hit. No change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First request per locale:&lt;/strong&gt; one KV read, but for the &lt;strong&gt;combined namespace bundle&lt;/strong&gt; under a versioned key. Any overlap between pages was cached separately (one cache entry for &lt;code&gt;common,landing&lt;/code&gt;, another for &lt;code&gt;common,landing,newsletter&lt;/code&gt;, another for just &lt;code&gt;common&lt;/code&gt;). Cache footprint was larger than strictly necessary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translation update:&lt;/strong&gt; required &lt;code&gt;npm run i18n:migrate&lt;/code&gt; AND &lt;code&gt;npm run deploy&lt;/code&gt;. The migrate step pushed to KV; the deploy step updated &lt;code&gt;TRANSLATIONS_VERSION&lt;/code&gt;. Without both steps, users saw stale content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache after update:&lt;/strong&gt; every versioned cache entry for every locale-namespace combination became orphaned all at once. LRU eventually cleaned them up. Cache stampede on the first request to any page in any locale that hadn't been warmed under the new version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The new architecture eliminates the deploy requirement, eliminates cache bundle duplication, and reduces the cache stampede from "every entry" to "only changed entries." Performance on the hot path is identical to the old one - both architectures deliver zero KV reads when the cache is warm. The improvement is entirely in the invalidation path, which happens at deploy time rather than at request time.&lt;/p&gt;

&lt;p&gt;Which is, finally, what Part 1 promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs &amp;amp; When to Use Which Approach
&lt;/h2&gt;

&lt;p&gt;Software architecture writing has a bad habit of pretending there's one right answer. There rarely is. Part 1's content-hash approach and Part 3's explicit-purge approach are both valid - they just solve the same problem under different constraints, for different project shapes.&lt;/p&gt;

&lt;p&gt;Rather than steer you toward one, let me describe the actual decision criteria I'd use myself. The two architectures live in two separate branches of the repo, and picking between them is a legitimate choice you should make consciously.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Short Version
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;v1-version-based-cache&lt;/code&gt; when your situation has any of these properties:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You don't want to manage an additional Cloudflare API token.&lt;/li&gt;
&lt;li&gt;Your translations rarely change - maybe once a month, or only at release time.&lt;/li&gt;
&lt;li&gt;It's a solo project or a small team where "run two commands instead of one" isn't a real concern.&lt;/li&gt;
&lt;li&gt;You're still in early development and just want something that works without additional configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;main&lt;/code&gt; (the Part 3 architecture) when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You've got a custom domain proxied through Cloudflare (orange cloud DNS).&lt;/li&gt;
&lt;li&gt;Translations change independently from code - content editors, translators, or a CMS pushing updates.&lt;/li&gt;
&lt;li&gt;You want a non-developer to be able to deploy content changes without any help.&lt;/li&gt;
&lt;li&gt;Translation update velocity matters enough that "seconds to propagate" is better than "wait for a deploy."&lt;/li&gt;
&lt;li&gt;You plan to grow into a workload where avoiding redundant cache stampedes actually matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxmd3dkvv7p0yl5svowrr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxmd3dkvv7p0yl5svowrr.jpg" alt="Decision matrix for choosing edge-native i18n cache invalidation architecture: version-based V1 branch for solo developers with infrequent updates on workers.dev versus Purge API main branch for decoupled teams with daily content updates on proxied custom domains." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 1 - Proxied Domain Requirement
&lt;/h3&gt;

&lt;p&gt;Before we get to the axis that differentiates the two approaches, it's worth being clear about what they share. &lt;strong&gt;Both&lt;/strong&gt; architectures need an orange-clouded (proxied) custom domain to perform any edge caching at all.&lt;/p&gt;

&lt;p&gt;Cloudflare's Workers Cache API is tied to zone-level caching, which simply doesn't exist on &lt;code&gt;*.workers.dev&lt;/code&gt; subdomains or on custom domains with grey-cloud DNS-only mode. In either of those configurations, &lt;code&gt;cache.put&lt;/code&gt; silently discards, &lt;code&gt;cache.match&lt;/code&gt; always returns &lt;code&gt;undefined&lt;/code&gt;, and every request falls through to KV. This is true regardless of which branch you're on.&lt;/p&gt;

&lt;p&gt;Where the two architectures diverge is what happens when you don't have that proxied domain.&lt;/p&gt;

&lt;p&gt;For Part 1, it's mostly fine - the version-keyed caching doesn't fail, it just doesn't do anything. Translations are read from KV on every request, which is slower than a cache hit but correct. For a low-traffic preview or a pre-launch build, the KV free tier (100k reads/day) is generously more than you'll ever need.&lt;/p&gt;

&lt;p&gt;For Part 3, the same is true for the Cache API, but the Purge API is an additional piece that also requires the proxied setup. Without it, &lt;code&gt;i18n:migrate&lt;/code&gt; will happily run - it pushes to KV, reports a successful purge API call, updates the hash file - but none of that actually does anything to invalidate cache, because there's no cache in front of the Worker to begin with. The script doesn't know this. It just quietly produces a no-op where you expected surgical invalidation.&lt;/p&gt;

&lt;p&gt;So if you know your domain won't be proxied any time soon, Part 3 isn't broken per se, but its signature feature is inactive. Either stick with the Part 1 branch (which doesn't depend on Purge API at all), or set &lt;code&gt;I18N_CACHE=off&lt;/code&gt; and accept the slightly higher KV read volume in exchange for guaranteed freshness.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 2 - Operational Complexity Tolerance
&lt;/h3&gt;

&lt;p&gt;Part 3 requires more moving parts than Part 1 does. That's just true, and pretending otherwise would be dishonest.&lt;/p&gt;

&lt;p&gt;Specifically, to adopt Part 3 you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Cloudflare API token with &lt;code&gt;Cache Purge&lt;/code&gt; permission on your zone.&lt;/li&gt;
&lt;li&gt;That token stored in &lt;code&gt;.dev.vars&lt;/code&gt; (which must be gitignored).&lt;/li&gt;
&lt;li&gt;The zone ID in &lt;code&gt;wrangler.jsonc&lt;/code&gt; as a Wrangler variable.&lt;/li&gt;
&lt;li&gt;Awareness of what &lt;code&gt;.i18n-hashes.json&lt;/code&gt; is and why it's gitignored.&lt;/li&gt;
&lt;li&gt;Basic understanding of what the graceful-recovery pattern means, so that "purge failed, rerun migrate" doesn't panic you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is individually hard, but the sum is "about half an hour of additional setup, done once" on top of the baseline shared with Part 1. Both branches still need a KV namespace, a Worker deploy, and a proxied domain - Part 3 just adds a purge token, zone ID, and hash-file awareness on top.&lt;/p&gt;

&lt;p&gt;For a solo project where you're both the developer and the content editor, that additional half-hour is real. It might be more than the entire translation-update time you'll spend all year. Part 1 is strictly simpler, and simpler is a valid choice when complexity doesn't earn its keep.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 3 - Content Update Frequency
&lt;/h3&gt;

&lt;p&gt;This is where the decision actually lives for most projects.&lt;/p&gt;

&lt;p&gt;If translations change once a month on release day, the redeploy requirement of the Part 1 architecture is close to invisible - you're deploying anyway, whether for content or for code. Nobody on the team will notice the coupling because it mirrors their natural cadence.&lt;/p&gt;

&lt;p&gt;If translations change weekly, the redeploy requirement starts feeling heavy. Not fatal, but it means every German typo fix requires running two commands and watching a deploy pipeline. Part 3 makes this one command.&lt;/p&gt;

&lt;p&gt;If translations change daily - say, a marketing team adjusting hero copy, or a CMS with real content authors pushing updates - the Part 1 approach becomes actively painful. Every content tweak blocks on a deploy. Part 3 reduces this to a single command that takes seconds and affects zero production code.&lt;/p&gt;

&lt;p&gt;There's also a second-order effect. When translation updates are cheap and decoupled, you start using them more. Small copy fixes that weren't worth a deploy become casual - a typo caught in a screenshot, a better phrasing proposed by a reviewer, an A/B test variant, a hero headline being retuned to match a new ranking keyword, a meta description being rewritten after a Search Console report.&lt;/p&gt;

&lt;p&gt;The cost structure shapes behavior. Part 3 makes translation iteration cheap enough that you actually iterate - including the kind of continuous SEO-driven copy refinement that most projects silently abandon because "it's not worth redeploying for."&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 4 - Team Composition
&lt;/h3&gt;

&lt;p&gt;Part 1's architecture has a hidden assumption baked into it: translations and code changes flow through the same deploy pipeline, which implies they flow through the same person or team. A content editor who can't deploy a Worker can't deploy a translation update either, because the Worker embeds the translation version.&lt;/p&gt;

&lt;p&gt;For solo projects, this is fine. You wear both hats.&lt;/p&gt;

&lt;p&gt;For two-person teams where both are developers, still fine.&lt;/p&gt;

&lt;p&gt;For teams where content is owned by a non-developer - a copywriter, a translator, a product manager - Part 1's architecture doesn't scale. Either the non-developer needs deploy access they shouldn't have, or every translation change creates a deploy bottleneck for whoever does.&lt;/p&gt;

&lt;p&gt;Part 3 solves this cleanly: the migration script is a command-line tool, and &lt;code&gt;wrangler kv bulk put&lt;/code&gt; plus &lt;code&gt;i18n:migrate&lt;/code&gt; can be triggered by anyone who has the tokens, independent of whether they ever touch the code.&lt;/p&gt;

&lt;p&gt;If you plan to let a non-developer update translations, Part 3 is not an optimization - it's a prerequisite.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 5 - Scale and Cache Bloat
&lt;/h3&gt;

&lt;p&gt;At small scale this axis doesn't matter. At larger scale it starts to.&lt;/p&gt;

&lt;p&gt;Part 1 caches translations under &lt;strong&gt;combined&lt;/strong&gt; keys: every unique set of namespaces that any page requests becomes its own cache entry. A page pulling &lt;code&gt;common,landing&lt;/code&gt; creates one entry. A page pulling &lt;code&gt;common,landing,newsletter&lt;/code&gt; creates another. A page pulling just &lt;code&gt;common&lt;/code&gt; creates a third. Same underlying translation data, three cache entries.&lt;/p&gt;

&lt;p&gt;For a small site with a handful of page types, this duplication is invisible. For a site with many page types and many locales, the multiplicative effect starts to add up - you might have 5 locales × 20 distinct namespace-set combinations = 100 cache entries for content that fundamentally represents 5 × 10 = 50 unique namespace loads.&lt;/p&gt;

&lt;p&gt;Part 3's per-namespace keys eliminate this duplication entirely. One cache entry per &lt;code&gt;locale:namespace&lt;/code&gt; pair. Full stop. Cache footprint is predictable and scales linearly with locales × namespaces, not with page-template count.&lt;/p&gt;

&lt;p&gt;This probably doesn't matter until you're running a content-heavy multilingual site with dozens of page templates. But when it does matter, Part 1 requires a painful retrofit to fix. Part 3 gets it right by construction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 6 - Recovery from Partial Failures
&lt;/h3&gt;

&lt;p&gt;Both architectures handle KV failures gracefully via fallback dictionaries. Where they diverge is what happens when the &lt;strong&gt;invalidation step&lt;/strong&gt; itself fails.&lt;/p&gt;

&lt;p&gt;In Part 1, invalidation is implicit - change the content, the hash changes, old cache keys orphan. There's nothing to fail. If your &lt;code&gt;i18n:migrate&lt;/code&gt; script errors out mid-run after pushing to KV, users start seeing new content on the next request via the new hash. No manual recovery needed.&lt;/p&gt;

&lt;p&gt;In Part 3, invalidation is explicit via a Purge API call. That call can fail - rate limits, network errors, invalid token, zone misconfiguration. We built the graceful recovery pattern specifically to handle this: KV gets pushed first, hash file only updates if purge succeeds, so retrying &lt;code&gt;i18n:migrate&lt;/code&gt; replays the same invalidation automatically. But it's still an extra failure mode to reason about, and an extra thing to check in your operational runbook.&lt;/p&gt;

&lt;p&gt;If your project has strict uptime and observability requirements and you're running this at scale, Part 1 is simpler to operate. Fewer moving pieces means fewer things to page you in the middle of the night.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing Between Content-Hash and Purge API Architectures
&lt;/h3&gt;

&lt;p&gt;Honestly, for a solo indie project just getting started, I'd probably still reach for the Part 1 architecture first. It's ~20 lines of additional setup to eliminate, and it lets you ignore an entire category of operational concerns.&lt;/p&gt;

&lt;p&gt;Later, when the project has scale and a content workflow that justifies the complexity, you can migrate to Part 3 - the branches exist in the same repo and the migration is small and localized.&lt;/p&gt;

&lt;p&gt;Start with the simpler one. Upgrade when you actually need to.&lt;/p&gt;

&lt;p&gt;The reason I ended up shipping Part 3 for edgekits.dev is that I knew the content workflow I wanted - non-developer updates, fast iteration, a path toward real content management - was incompatible with the redeploy requirement. For that specific project, Part 3 wasn't optional.&lt;/p&gt;

&lt;p&gt;For your project, the answer depends on which of the six axes above dominate your constraints. The nice thing about having both branches is that you don't have to commit to the answer before building anything. Pick the simpler one, build, and switch later if the axes shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Three articles in, let me step back and look at what the whole arc has actually been about.&lt;/p&gt;

&lt;p&gt;Part 1 argued that translations are data, not code, and proposed an architecture that moved them into KV and cached them at the edge.&lt;/p&gt;

&lt;p&gt;Part 2 extended that philosophy to user-facing forms and their server-side error pathstranslated content flowing through validation, through API responses, through locale-aware error codes, all without client-side i18n bundles.&lt;/p&gt;

&lt;p&gt;And Part 3, as it turned out, was about finishing what Part 1 started.&lt;/p&gt;

&lt;p&gt;Because I had moved translations into KV, I genuinely believed I had separated them from code. But the cache invalidation layers small, technical-looking detail I didn't think much about at the timekept them coupled.&lt;/p&gt;

&lt;p&gt;Every content edit still required a code deploy, because the thing that told the cache "this content is stale" lived inside the code. The philosophy was right. The implementation didn't fully live up to it.&lt;/p&gt;

&lt;p&gt;The resolution, once I found it, came from a small reframing. Not "how do I make the cache key reflect content changes?"which led me through three architectures that each had their own regression. But "how do I delete specific cache entries when content changes?"&lt;/p&gt;

&lt;p&gt;That framing pointed directly at a Cloudflare primitive I'd been ignoring: the Purge API. And once I started treating invalidation as an explicit operation on data rather than an implicit consequence of cache-key rotation, the coupling dissolved. Not moved. Dissolved.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Pattern Underneath: State Lifecycles on the Edge
&lt;/h3&gt;

&lt;p&gt;I think there's a generalizable insight here, and it's less about i18n and more about how we reason about state at the edge.&lt;/p&gt;

&lt;p&gt;When you first start building on Workers, you tend to think of caches and KV and env vars as interchangeable places where state can live. You pick whichever one "feels right" for a particular piece of data. But each of these layers has a specific purpose that matches a specific lifecycle, and fighting that alignment produces subtle couplings you don't notice until you try to evolve the system.&lt;/p&gt;

&lt;p&gt;Environment variables are for how the Worker is configured. They live with the Worker. They change when the Worker changes.&lt;/p&gt;

&lt;p&gt;KV is for durable data that the Worker reads. It lives independently of the Worker. It can change without the Worker knowing.&lt;/p&gt;

&lt;p&gt;The Cache API is for transient acceleration. It's downstream of KV. It gets populated by the Worker and exists to save network round-trips.&lt;/p&gt;

&lt;p&gt;If you store versioning information in an env var, you're saying "this version is part of the Worker's identity"and you'll pay for that every time the version should change without the Worker's identity changing. If you store content in an env var, you're saying "this content changes at the same rate as my code"which is true for feature flags, maybe, but wrong for translations.&lt;/p&gt;

&lt;p&gt;The refactor in this article was, underneath the surface detail, really an exercise in putting each piece of state in the right layer and letting the platform's natural lifecycles handle propagation. Content goes in KV. Configuration stays in env vars. Cache entries are downstream of both. Invalidation is an explicit operation between them, not a byproduct of key naming.&lt;/p&gt;

&lt;p&gt;Once the pieces sit in their right layers, the whole system composes cleanly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbcu26s5k69kk4hsndr1l.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbcu26s5k69kk4hsndr1l.jpg" alt="Refined edge-native i18n architectural stack: Astro middleware and SSR on top, Cloudflare Cache API as the edge shield layer, Cloudflare KV as the source of truth at the bottom, with Purge API performing external invalidation." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What "Data, Not Code" Actually Means
&lt;/h3&gt;

&lt;p&gt;The original claim from Part 1 was that translations should be data, not code. That's a slogan. Part 3 made me articulate what it actually means operationally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The format in which content is stored doesn't require recompilation to update. (KV stores JSON. Editing JSON isn't "changing the code.")&lt;/li&gt;
&lt;li&gt;The pathway through which content propagates doesn't intersect the code deploy pipeline. (A translation update calls &lt;code&gt;wrangler kv bulk put&lt;/code&gt;, not &lt;code&gt;wrangler deploy&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;The invalidation signal that tells downstream caches to refresh is itself data, not code. (A Purge API call triggered by a script, not a new hash baked into a bundle.)&lt;/li&gt;
&lt;li&gt;The lifecycle of the content is independent of the lifecycle of the Worker. (The Worker runs for weeks while translations update daily beneath it.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxioh8yaly3irg2b2toyo.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxioh8yaly3irg2b2toyo.jpg" alt="Data-not-code architectural philosophy for edge-native i18n: configuration stays in wrangler.jsonc env vars tied to Worker lifecycle, content lives in Cloudflare KV with independent lifecycle, acceleration happens in Cache API downstream with explicit invalidation." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All four bullets have to be true simultaneously for "data, not code" to be more than an aspiration. Part 1 delivered the first two. Part 3 delivered the other two. You need all four before non-developer teammates can actually own translations without needing a developer.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Comes Next
&lt;/h3&gt;

&lt;p&gt;There's a larger question humming underneath this article, and I'm going to name it rather than hide it: &lt;strong&gt;what else in a typical SaaS is quietly shipped as code when it should be shipped as data?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Feature flags. A/B test variants. UI copy outside the i18n system. Pricing configurations. Error message templates. Marketing banners. Onboarding step sequences. Email templates.&lt;/p&gt;

&lt;p&gt;All of these routinely live in code repositories, get deployed alongside features, and create the same kind of subtle coupling between product changes and engineering cycles that I just spent 8,000 words untangling for translations.&lt;/p&gt;

&lt;p&gt;I don't have a general solution to offer heredifferent types of data have different invalidation patterns, different consistency requirements, different audiences. But the framework I arrived at for i18n static cache keys, explicit invalidation, graceful recovery, per-item granularityseems to generalize.&lt;/p&gt;

&lt;p&gt;A future post might walk through how the same patterns apply to feature flags, or to lightweight CMS-backed content. We'll see.&lt;/p&gt;

&lt;p&gt;For now, what I can offer is this: if you've been accumulating that vague sense of "deploys are weirdly tangled up with content operations on our project," you're probably not imagining it.&lt;/p&gt;

&lt;p&gt;The tangle is usually real. And it's usually fixable by asking a simple question for each piece of data in your system - "what's its natural lifecycle, and which part of my platform is designed for exactly that?" - and then storing it there, directly, instead of cramming it into whichever place was convenient at the time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Thank You For Reading
&lt;/h3&gt;

&lt;p&gt;This has been a long series. Thank you for sticking with it.&lt;/p&gt;

&lt;p&gt;If you build something on this stack, I'd love to hear about it. And if you found these articles useful, a star on the repo is always appreciated - it's not something I chase, but it genuinely feels good to see one land. The code from both architectures is open source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/EdgeKits/astro-edgekits-core" rel="noopener noreferrer"&gt;
The main branch of astro-edgekits-core
&lt;/a&gt;
contains the Part 3 architecture described here.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/EdgeKits/astro-edgekits-core/tree/v1-version-based-cache" rel="noopener noreferrer"&gt;
The v1-version-based-cache branch
&lt;/a&gt;
preserves the Part 1 architecture, which remains a legitimate choice for the
use cases outlined in the trade-offs section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More deep dives are already brewing - this stack has a lot of surface area and I want to keep covering it properly rather than hand-waving at the interesting parts. What comes next will depend on where the starter kits land and what the most common friction points turn out to be in practice.&lt;/p&gt;

&lt;p&gt;For now, go ship a German typo fix without redeploying ;)&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>astro</category>
      <category>cloudflare</category>
      <category>cache</category>
    </item>
    <item>
      <title>Stop Shipping Translations to the Client: Edge-Native i18n with Astro &amp; Cloudflare (Part 2)</title>
      <dc:creator>Gary Stupak</dc:creator>
      <pubDate>Tue, 31 Mar 2026 09:16:10 +0000</pubDate>
      <link>https://forem.com/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-2-359n</link>
      <guid>https://forem.com/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-2-359n</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu8i7uaw98lhrfza473uf.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu8i7uaw98lhrfza473uf.jpg" alt="Conceptual 3D illustration of the Client Bundle Trap: a heavy Zod localization dictionary (zod-i18n-map) crushing a React Island, demonstrating how client-side translation JSONs bloat the React bundle and ruin Core Web Vitals like Total Blocking Time." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What you are about to see in this article is not a search for easy paths.&lt;/p&gt;

&lt;p&gt;Let me be upfront (and I probably should have mentioned this in &lt;a href="https://dev.to/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-1-5b38"&gt;Part 1&lt;/a&gt;: if you have a simple site with two or three pages, two languages, and no interactive React Islands - just use Astro's built-in i18n routing, build it to static (&lt;code&gt;output: 'static'&lt;/code&gt;), and don't overcomplicate your life.&lt;/p&gt;

&lt;p&gt;But if it's a complex marketing site and/or you are building a B2B SaaS with a dynamic dashboard, tons of forms, and UGC, where users generate data and marketing demands green LCP metrics despite heavy trackers - that's when classic approaches break down, and our custom architecture pays back every minute invested in its maintenance.&lt;/p&gt;

&lt;p&gt;All of this is dictated by my pragmatic love (if love can be pragmatic :)) for this stack and the desire to achieve maximum user convenience alongside premium Lighthouse metrics, Core Web Vitals, and proper SEO.&lt;/p&gt;

&lt;p&gt;For a modern business, high website performance is a baseline condition for survival. You cannot count on intensive organic traffic and ad ROI if your architecture allows a "beautiful" React component to block the main thread for three long seconds.&lt;/p&gt;

&lt;p&gt;At first glance, this task is woven from unsolvable contradictions - well then, let's try to tackle it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spoiler alert:&lt;/strong&gt; I already realize that fitting all aspects of SEO-readiness and dynamic database localization into this single post is impossible. So today, I will focus strictly on the technical implementation of what was promised in Part 1.&lt;/p&gt;

&lt;p&gt;We are still building on top of the &lt;a href="https://github.com/EdgeKits/astro-edgekits-core" rel="noopener noreferrer"&gt;Astro EdgeKits Core&lt;/a&gt; foundation, but with expanded cases. I will show you everything exactly as it is implemented &lt;strong&gt;in production on edgekits.dev&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The first bottleneck where the Zero-JS Astro i18n concept usually breaks down is client-side form validation. Let's see how to master react-hook-form and Zod localization on the Edge, making them work seamlessly with Shadcn UI - all without shipping heavy JSON dictionaries or client-side translation engines to the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the Hood: The Subscription Flow Stack
&lt;/h2&gt;

&lt;p&gt;To demonstrate this architecture, we will dissect the Newsletter Subscription flow. It's not just a single &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;; it's a two-step State Machine (Subscribe -&amp;gt; Segment -&amp;gt; Done) that interacts with our database.&lt;/p&gt;

&lt;p&gt;Here is the tooling we use to make it happen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; Cloudflare D1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORM &amp;amp; Schema Validation:&lt;/strong&gt; Drizzle (&lt;code&gt;drizzle-orm&lt;/code&gt;, &lt;code&gt;drizzle-kit&lt;/code&gt;, &lt;code&gt;drizzle-zod&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server Logic:&lt;/strong&gt; Astro Actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client State Management:&lt;/strong&gt; &lt;code&gt;react-hook-form&lt;/code&gt;, &lt;code&gt;@hookform/resolvers&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI Components:&lt;/strong&gt; Extended Shadcn UI (&lt;code&gt;FieldGroup&lt;/code&gt;, &lt;code&gt;Field&lt;/code&gt;, &lt;code&gt;FieldLabel&lt;/code&gt;, &lt;code&gt;Input&lt;/code&gt;, &lt;code&gt;Select&lt;/code&gt;, and &lt;code&gt;MultiSelect&lt;/code&gt; from the &lt;a href="https://wds-shadcn-registry.netlify.app/" rel="noopener noreferrer"&gt;WebDevSimplified (WDS) Shadcn Registry by Kyle Cook&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Bottleneck: Zod i18n and Client Bundle Bloat
&lt;/h3&gt;

&lt;p&gt;When you build an interactive form in React, the industry-standard reflex is to pair &lt;code&gt;react-hook-form&lt;/code&gt; with &lt;code&gt;Zod&lt;/code&gt; for validation.&lt;/p&gt;

&lt;p&gt;But when you need to internationalize those validation errors (e.g., turning "Invalid email" into "Correo electrónico no válido"), the standard ecosystem pushes you toward packages like &lt;code&gt;zod-i18n-map&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a dead end for performance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To make it work, you have to ship the entire Zod translation dictionary to the client. Suddenly, your carefully optimized, lightweight React Island is dragging an extra 30-50KB of JSON and localization logic into the browser. The main thread chokes, TBT (Total Blocking Time) spikes, and your Web Vitals turn yellow.&lt;/p&gt;

&lt;p&gt;We need to validate data on the client to provide instant feedback, but we cannot afford to ship the translations. How do we break this loop?&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero-JS Validation: Error Codes as a Domain Contract
&lt;/h2&gt;

&lt;p&gt;The root of the problem is treating an error as a string of text.&lt;/p&gt;

&lt;p&gt;In a typical application, the UI, the API routes, and the domain logic all know about the &lt;code&gt;t()&lt;/code&gt; function. Errors are translated the moment they are created. This creates a chaotic system where it is impossible to understand where an error originated, how to log it, or how to reliably change its language context.&lt;/p&gt;

&lt;p&gt;In EdgeKits, we introduced a strict paradigm shift: &lt;strong&gt;An error is a part of the domain, not the UI.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F20grmbs04715c4cyij8t.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F20grmbs04715c4cyij8t.jpg" alt="Conceptual diagram showing the shift from UI-coupled translation anti-patterns to strict literal error codes in a Zero-JS Edge-Native i18n architecture" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Strict Literal Types
&lt;/h3&gt;

&lt;p&gt;We replaced translated strings with strict, language-agnostic literal types. We created a single, unified dictionary of error codes for the entire application.&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;// src/domain/messages/error-codes.ts&lt;/span&gt;

&lt;span class="c1"&gt;// Server actions/apis errors&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SERVER_ERROR_CODES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;INTERNAL_SERVER_ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INTERNAL_SERVER_ERROR&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="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ServerErrorCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;SERVER_ERROR_CODES&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;SERVER_ERROR_CODES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;// UI / Validation Errors&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UI_ERROR_CODES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Newsletter / identity&lt;/span&gt;
  &lt;span class="na"&gt;INVALID_EMAIL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INVALID_EMAIL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;EMAIL_ALREADY_EXISTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EMAIL_ALREADY_EXISTS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;FAILED_TO_INSERT_SUBSCRIBER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FAILED_TO_INSERT_SUBSCRIBER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Segmentation&lt;/span&gt;
  &lt;span class="na"&gt;INTERESTS_REQUIRED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INTERESTS_REQUIRED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;BILLING_OTHER_REQUIRED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BILLING_OTHER_REQUIRED&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="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;UiErrorCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;UI_ERROR_CODES&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;UI_ERROR_CODES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;// Merge for usecases where both groups are needed&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;SERVER_ERROR_CODES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;UI_ERROR_CODES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ErrorMessageCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;as const&lt;/code&gt; ensures these are strict literal types, not generic strings.&lt;/li&gt;
&lt;li&gt;We avoid TypeScript &lt;code&gt;enum&lt;/code&gt;s, which are better for edge compatibility and serialization.&lt;/li&gt;
&lt;li&gt;There is only one set of error codes across the entire product.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: Zod Speaks in Codes
&lt;/h3&gt;

&lt;p&gt;Now, we enforce this contract at the schema level. When we define our &lt;code&gt;Zod&lt;/code&gt; schema for the newsletter form, we don't write human-readable messages. We map the validation failures directly to our domain codes.&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;// src/db/forms.ts&lt;/span&gt;

&lt;span class="c1"&gt;// In reality, this schema is wider and collects analytics data (e.g. subscription source). We are omitting those fields here for brevity and focusing on validation.&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createInsertSchema&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drizzle-zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;subscribers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/db/schema&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SubscriberInsertSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createInsertSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscribers&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;const&lt;/span&gt; &lt;span class="nx"&gt;NewsletterFormSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SubscriberInsertSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// Zod returns a domain code instead of a string&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INVALID_EMAIL&lt;/span&gt; &lt;span class="p"&gt;}),&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;type&lt;/span&gt; &lt;span class="nx"&gt;NewsletterFormData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;NewsletterFormSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a user enters &lt;code&gt;foo@bar&lt;/code&gt; into the client-side form, Zod doesn't try to figure out if the user is German or Japanese. It simply returns &lt;code&gt;"INVALID_EMAIL"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The validation logic is now completely decoupled from the localization layer. The client bundle remains incredibly small because it only contains the schema rules, not the dictionaries.&lt;/p&gt;

&lt;p&gt;But what happens when the error doesn't come from Zod, but from the backend? This is where Astro Actions step in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Astro Actions Error Handling &amp;amp; The &lt;code&gt;DomainContext&lt;/code&gt; Pattern
&lt;/h2&gt;

&lt;p&gt;So, Zod now returns &lt;code&gt;"INVALID_EMAIL"&lt;/code&gt; instead of a human-readable string. But client-side validation is only the first line of defense. What happens when the data is valid, but the business logic fails on the backend? For example, the user submits an email that already exists in your database.&lt;/p&gt;

&lt;p&gt;In Astro, the bridge between the client and the server is handled by &lt;strong&gt;Astro Actions&lt;/strong&gt;. However, when running on Cloudflare Workers, we face a unique architectural challenge: bindings.&lt;/p&gt;

&lt;p&gt;To access your D1 database or KV namespaces, you need the Cloudflare &lt;code&gt;Env&lt;/code&gt; object. Passing this &lt;code&gt;env&lt;/code&gt; object through every single service, repository, and utility function is a notorious DX nightmare that pollutes your domain logic with infrastructure details.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(A quick side note: If you are already using the shiny new Astro 6.x with the updated Cloudflare adapter, you can now &lt;code&gt;import { env } from 'cloudflare:workers'&lt;/code&gt; directly anywhere in your server code. However, this project is built on Astro 5.x, where &lt;code&gt;env&lt;/code&gt; is strictly injected into the request context. More importantly, regardless of the framework version, keeping infrastructure imports out of your domain logic remains a superior architectural pattern for testability and decoupling).&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;DomainContext&lt;/code&gt; Solution
&lt;/h3&gt;

&lt;p&gt;To keep our actions clean and our domain edge-native, we use a strict &lt;strong&gt;DomainContext pattern&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;DomainContext&lt;/code&gt; is a request-scoped composition root. It is the &lt;em&gt;only&lt;/em&gt; place where the Cloudflare &lt;code&gt;Env&lt;/code&gt; is used to wire up repositories and services. The Astro Action simply creates the context and delegates the work.&lt;/p&gt;

&lt;p&gt;Here is a simplified diagram of this architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    A[Astro Action] --&amp;gt;|Passes Env| B(createNewsletterContext)
    B --&amp;gt;|Initializes| C[NewsletterDrizzleRepository]
    B --&amp;gt;|Wires up| D[Domain Services]
    D -.-&amp;gt;|Uses| C
    C -.-&amp;gt;|Queries| E[(Cloudflare D1)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's break down what is happening here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astro Action:&lt;/strong&gt; The starting point. This is the Astro server function that receives data from the user. It passes the environment variables (Cloudflare bindings) further down the chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;createNewsletterContext:&lt;/strong&gt; The initializer. It creates the execution "context" - gathering all the necessary tools for the newsletter operations in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NewsletterDrizzleRepository:&lt;/strong&gt; The data access layer. It uses the Drizzle ORM to translate our code into raw SQL queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain Services:&lt;/strong&gt; The business logic. This is the "brain" of the application that decides exactly what needs to be done with the data before saving it. It uses the repository to communicate with the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare D1:&lt;/strong&gt; The final destination. The serverless relational SQL database on the Cloudflare platform where the data is physically stored.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The takeaway:&lt;/strong&gt; What we have here is a classic manual &lt;strong&gt;Dependency Injection&lt;/strong&gt; pattern. The business logic (Services) is completely decoupled from the database operations (Repository), and everything is cleanly wired together inside a single context the exact moment the Action is invoked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuue4uewawqg347x9ivc7.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuue4uewawqg347x9ivc7.jpg" alt="Architecture flowchart of the DomainContext pattern in Astro Actions, demonstrating manual dependency injection to connect Cloudflare Workers Env and Drizzle ORM to D1 without polluting business logic" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here is the actual implementation from our codebase:&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;// src/domain/newsletter/context.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NewsletterDrizzleRepository&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./repository&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validateContact&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./services&lt;/span&gt;&lt;span class="dl"&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;createNewsletterContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Initialize the concrete repository with Env (D1 binding)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NewsletterDrizzleRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Return the public API of the domain&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nf"&gt;validateContact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;email&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="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// The service knows about the repository interface, but knows nothing about Env&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;validateContact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="na"&gt;addContactToDb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertContact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To complete the picture, let's take a look at the validateContact service itself, which is invoked inside the context:&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;// src/domain/newsletter/services/validate-contact.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;checkEmailExists&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./validation/check-email-exists&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NewsletterRepository&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../repository/interface&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&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;validateContact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NewsletterRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;email&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="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkEmailExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&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;But where does that strict error code actually come from? Let's go one level deeper into the checkEmailExists helper to see how the circle completes:&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;// src/domain/newsletter/services/validation/check-email-exists.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/messages/error-codes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NewsletterRepository&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../repository&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&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;checkEmailExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NewsletterRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;email&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;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;exists&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;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsByEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&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;exists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// We throw the strict domain code, not a localized string!&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EMAIL_ALREADY_EXISTS&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the core of our error contract. When the database confirms the email is taken, we don't throw a generic Error("Email already in use") or trigger a translation function. We throw our strict literal type. This error bubbles up through the validateContact service, gets caught by the Astro Action, and is safely passed to the React Orchestrator without ever exposing database internals or coupling the backend to a specific UI language.&lt;/p&gt;

&lt;p&gt;Everything here is crystal clear: the services layer knows absolutely nothing about Cloudflare, the Env object, or the D1 database. It simply accepts a strict, abstract repository interface (NewsletterRepository) and executes pure business logic.&lt;/p&gt;

&lt;p&gt;This makes your domain 100% testable and completely independent of the underlying infrastructure. If you decide to migrate from D1 to PostgreSQL, or swap Drizzle for Prisma (or even raw SQL) tomorrow, this code won't change by a single line.&lt;/p&gt;

&lt;p&gt;Now, look how clean and readable the actual Astro Action becomes:&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;// src/actions/newsletter.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ActionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defineAction&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:actions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NewsletterActionInputSchema&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./schema&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createNewsletterContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/newsletter/context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isErrorMessageCode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newsLetter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defineAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NewsletterActionInputSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// 1. Initialize the domain context using Cloudflare Env&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newsletter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createNewsletterContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&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="c1"&gt;// 2. Execute business logic&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;newsletter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validateContact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Throws if email is filthy or exists&lt;/span&gt;

        &lt;span class="c1"&gt;// Enrich data with Cloudflare Geo-IP before saving&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt; &lt;span class="o"&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;subscriberId&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;newsletter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addContactToDb&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&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;subscriberId&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 3. Catch domain errors and safely escalate them to the client&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;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isErrorMessageCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;ActionError&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// e.g., "EMAIL_ALREADY_EXISTS"&lt;/span&gt;
            &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BAD_REQUEST&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="c1"&gt;// Fallback for unexpected system crashes&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;ActionError&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INTERNAL_SERVER_ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INTERNAL_SERVER_ERROR&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="p"&gt;},&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;&lt;strong&gt;Two important details here:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Input Schema:&lt;/strong&gt; Notice that we use &lt;code&gt;NewsletterActionInputSchema&lt;/code&gt; instead of directly reusing the database insert schema. Why? Because API inputs rarely match the database 1:1. The client sends a &lt;code&gt;locale&lt;/code&gt; and an &lt;code&gt;email&lt;/code&gt;, but the action enriches the payload with Cloudflare's &lt;code&gt;cf&lt;/code&gt; object (like &lt;code&gt;country&lt;/code&gt; and &lt;code&gt;city&lt;/code&gt;) before passing it to the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Type Guard (&lt;code&gt;isErrorMessageCode&lt;/code&gt;):&lt;/strong&gt; When the domain throws an error, we need to ensure we don't accidentally leak a raw SQL error or a stack trace to the frontend. &lt;code&gt;isErrorMessageCode&lt;/code&gt; is a strict TypeScript type guard that checks if &lt;code&gt;error.message&lt;/code&gt; exactly matches one of our predefined codes in &lt;code&gt;ERROR_MESSAGE_CODES&lt;/code&gt;. If it doesn't, we swallow it and return a generic &lt;code&gt;INTERNAL_SERVER_ERROR&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Reducing React Bundle Size: Lazy Loading &amp;amp; State
&lt;/h2&gt;

&lt;p&gt;We now have a client that sends data and a server that safely returns strict Error Codes. How does the UI manage this communication flow without turning into a tangled mess of &lt;code&gt;useEffect&lt;/code&gt; hooks?&lt;/p&gt;

&lt;p&gt;We decouple the UI components from the business process by introducing a custom hook: &lt;code&gt;useSubscribeNewsletter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This hook acts as the &lt;strong&gt;Orchestrator&lt;/strong&gt;. It doesn't know anything about CSS or HTML. Its only job is to manage the form's &lt;strong&gt;State Machine&lt;/strong&gt; (&lt;code&gt;subscribe&lt;/code&gt; -&amp;gt; &lt;code&gt;segment&lt;/code&gt; -&amp;gt; &lt;code&gt;done&lt;/code&gt;), communicate with the Astro Action, and route the Error Codes to the React components.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5pwjf2ew0iyi0tc0ouvp.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5pwjf2ew0iyi0tc0ouvp.jpg" alt="State machine diagram for a multi-step React Hook Form orchestrator, illustrating lazy loading and UI state transitions without CSS or HTML coupling" width="800" height="420"&gt;&lt;/a&gt;&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;// src/hooks/useSubscribeNewsletter.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:actions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isErrorMessageCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/messages&lt;/span&gt;&lt;span class="dl"&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;useSubscribeNewsletter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStep&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscribe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;segment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscribe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPending&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;actionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setActionError&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;subscriberId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSubscriberId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&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;subscribeAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setPending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&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;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newsLetter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&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;error&lt;/span&gt;&lt;span class="p"&gt;)&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;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BAD_REQUEST&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="c1"&gt;// Store the strict domain code (e.g., "EMAIL_ALREADY_EXISTS")&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isErrorMessageCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;setActionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="c1"&gt;// Fallback for an uncovered key&lt;/span&gt;
          &lt;span class="nf"&gt;setActionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INVALID_EMAIL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setActionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;setSubscriberId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Save ID for the next step&lt;/span&gt;
        &lt;span class="nf"&gt;setStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;segment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Move state machine forward&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="c1"&gt;// We will handle global toast notifications here later&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setPending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&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="c1"&gt;// segmentationAction omitted for brevity, but it follows the exact same pattern&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;actionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setActionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subscribeAction&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;h3&gt;
  
  
  The Performance Hack: Lazy Loading
&lt;/h3&gt;

&lt;p&gt;The State Machine pattern gives us a massive performance advantage.&lt;/p&gt;

&lt;p&gt;Our subscription flow has two parts: asking for the email (Step 1), and asking for the user's preferences via a complex &lt;code&gt;SegmentationForm&lt;/code&gt; using heavy Shadcn &lt;code&gt;Select&lt;/code&gt; and &lt;code&gt;MultiSelect&lt;/code&gt; components (Step 2).&lt;/p&gt;

&lt;p&gt;If we bundle all of this into one file, the client has to download the dropdown logic just to render a simple email input. Instead, we use React's &lt;code&gt;lazy&lt;/code&gt; feature right inside our main component, conditionally rendering UI based on the &lt;code&gt;step&lt;/code&gt; variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/islands/NewsletterFlow.tsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lazy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NewsletterForm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/forms/components/NewsletterForm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useSubscribeNewsletter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/hooks/useSubscribeNewsletter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// 1. We load the heavy Segmentation form ONLY when the user reaches that step&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LazySegmentationForm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/forms/components/SegmentationForm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kr"&gt;module&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SegmentationForm&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NewsletterFlow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;actionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;setActionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;subscribeAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;segmentationAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSubscribeNewsletter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 3: Success State&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;step&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newsletter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscribed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newsletter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscribed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2: Segmentation State (Lazy Loaded)&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;step&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;segment&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newsletter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LazySegmentationForm&lt;/span&gt;
          &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;segmentationAction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;actionError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;actionError&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;setActionError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setActionError&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 1: Initial Subscribe State (Rendered by default)&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;step&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscribe&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newsletter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NewsletterForm&lt;/span&gt;
          &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;'landing-hero'&lt;/span&gt;
          &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;subscribeAction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;actionError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;actionError&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;setActionError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setActionError&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because our State Machine explicitly defines the &lt;code&gt;step&lt;/code&gt; state, Webpack/Vite knows exactly when to request the next chunk of JavaScript.&lt;/p&gt;

&lt;p&gt;The user loads the page, downloads almost zero JS, types their email, and clicks "Subscribe." Only while the Astro Action is executing on the Cloudflare Worker does the browser silently download the chunk for the &lt;code&gt;SegmentationForm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is how you achieve a &lt;strong&gt;0ms Total Blocking Time (TBT)&lt;/strong&gt; on the initial load while still building a rich, interactive SaaS application.&lt;/p&gt;

&lt;p&gt;Now, we have a fully functioning flow that operates entirely on Domain Error Codes. The final piece of the puzzle is the &lt;strong&gt;Final Mile&lt;/strong&gt;: transforming those codes into human-readable, localized text right before they hit the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Mile: React Hook Form Localization
&lt;/h2&gt;

&lt;p&gt;We have successfully purged human-readable strings from our validation schemas and our server actions. Both Zod and Astro Actions now speak a universal, language-agnostic language of strict literal codes (like &lt;code&gt;"INVALID_EMAIL"&lt;/code&gt; or &lt;code&gt;"EMAIL_ALREADY_EXISTS"&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;But end-users don't speak in literal codes. They need to see "Invalid email address" in English, or "Correo electrónico no válido" in Spanish.&lt;/p&gt;

&lt;p&gt;If the domain logic is decoupled from the language, where exactly does the translation happen?&lt;/p&gt;

&lt;p&gt;It happens at the very boundary of our application - at the exact moment of rendering the React UI. This is the &lt;strong&gt;Final Mile&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Bridge: Passing Lightweight Dictionaries
&lt;/h3&gt;

&lt;p&gt;Instead of bundling a heavy i18n library (like &lt;code&gt;react-i18next&lt;/code&gt;) and initializing translation contexts inside our React tree, we treat translations as pure data.&lt;/p&gt;

&lt;p&gt;When the Astro server renders the page, it fetches the necessary translation namespace (e.g., &lt;code&gt;messages.json&lt;/code&gt;) from Cloudflare KV (or the Edge Cache) and passes it directly to the React Island as a standard prop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv2zlbmgf6ym7f3r34628.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv2zlbmgf6ym7f3r34628.jpg" alt="Diagram explaining SSR Dictionary Injection for Zero-JS React localization, where Astro server passes pure JSON translation props to a React Island, eliminating client-side i18n network requests" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/islands/NewsletterFlow.tsx (Simplified)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NewsletterFlow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 't' is just a lightweight JavaScript object containing our localized strings&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newsletter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NewsletterForm&lt;/span&gt;
      &lt;span class="c1"&gt;// We pass only the specific dictionary needed for errors&lt;/span&gt;
      &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;subscribeAction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;actionError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;actionError&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// This holds our strict code from the server&lt;/span&gt;
      &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&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;This is the essence of &lt;strong&gt;Zero-JS React Hook Form localization&lt;/strong&gt;. There are no network requests for JSON files from the client, no suspense boundaries, and no bulky i18n engines. The dictionary is just a POJO (Plain Old JavaScript Object) injected during Server-Side Rendering (SSR).&lt;/p&gt;

&lt;p&gt;To make this completely clear, here is what that lightweight &lt;code&gt;messages.json&lt;/code&gt; dictionary actually looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;locales/en/messages.json&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"INVALID_EMAIL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Invalid email address."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"EMAIL_ALREADY_EXISTS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This email address already exists."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"INTERESTS_REQUIRED"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Please select at least one product."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"BILLING_OTHER_REQUIRED"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Please specify your billing provider."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"INTERNAL_SERVER_ERROR"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Something went wrong. Please try again."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"common"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PENDING"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Please wait"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SUBSCRIPTION_SUCCEED"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Thanks for your subscription!"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the keys in the &lt;code&gt;ui&lt;/code&gt; object. They are not random strings; they exactly match the &lt;code&gt;ERROR_MESSAGE_CODES&lt;/code&gt; we defined in our domain contract. This is the missing link that ties the backend validation directly to the UI translation without any intermediary mapping logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Component: &lt;code&gt;FieldErrorLocalized&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Inside our form, we need a component that knows how to read our domain codes and translate them using the provided dictionary.&lt;/p&gt;

&lt;p&gt;Let's look at the anatomy of &lt;code&gt;&amp;lt;FieldErrorLocalized /&amp;gt;&lt;/code&gt;. It receives the error state from &lt;code&gt;react-hook-form&lt;/code&gt; (which originates from Zod) and the error state from our Action (which originates from the D1 database).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/ui/forms/FieldErrorLocalized.tsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FieldError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/ui/field&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mapErrorsToI18n&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./error-mapper&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ErrorLike&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;message&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;FieldErrorLocalizedProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;ErrorLike&lt;/span&gt; &lt;span class="c1"&gt;// Error from Zod (react-hook-form)&lt;/span&gt;
  &lt;span class="nx"&gt;actionError&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="c1"&gt;// Error from Astro Action&lt;/span&gt;
  &lt;span class="nx"&gt;tErrors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;// Our lightweight dictionary&lt;/span&gt;
  &lt;span class="nx"&gt;className&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;FieldErrorLocalized&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;actionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tErrors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;FieldErrorLocalizedProps&lt;/span&gt;&lt;span class="p"&gt;)&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fieldError&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;actionError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

  &lt;span class="c1"&gt;// We map the domain codes to actual localized strings&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapErrorsToI18n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;actionError&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;actionError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nx"&gt;tErrors&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

  &lt;span class="c1"&gt;// We render the standard Shadcn UI FieldError component&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FieldError&lt;/span&gt;
      &lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&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 component is entirely dumb. It doesn't know what language the user selected. It simply delegates the translation to a pure mapping function.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Pure Mapper
&lt;/h3&gt;

&lt;p&gt;Here is the function that performs the actual translation. Because Zod and our Actions both output the domain code inside the &lt;code&gt;message&lt;/code&gt; property, the mapping logic is beautifully simple:&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;// src/ui/forms/error-mapper.ts&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ErrorLike&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;message&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="cm"&gt;/**
 * Maps multiple error-like objects (containing domain codes) to localized messages.
 */&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;mapErrorsToI18n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ErrorLike&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tErrors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ErrorLike&lt;/span&gt;&lt;span class="p"&gt;[]&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;issues&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// 1. Check if an error exists&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;

      &lt;span class="c1"&gt;// 2. Use the domain code (e.g., "INVALID_EMAIL") as a key in the dictionary&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tErrors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

      &lt;span class="c1"&gt;// 3. If no translation is found, we don't render an empty string&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;localized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;

      &lt;span class="c1"&gt;// 4. Return the translated string back in the expected format&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;localized&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ErrorLike&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;h3&gt;
  
  
  The Result
&lt;/h3&gt;

&lt;p&gt;By isolating localization to the very edges of the UI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Forms are decoupled:&lt;/strong&gt; They don't know about i18n or Zod. They just pass errors down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Domain is clean:&lt;/strong&gt; Zod schemas and Astro Actions use strict, type-safe literal codes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Bundle is tiny:&lt;/strong&gt; We completely eliminated the need for &lt;code&gt;zod-i18n-map&lt;/code&gt; and any client-side localization engines. The user downloads only the exact strings needed for the current screen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This architecture scales perfectly. Whether you add toast notifications, global process errors, or new languages, the core domain logic remains untouched, and the client performance remains at an excellent 90+.&lt;/p&gt;

&lt;h2&gt;
  
  
  Process Errors and Global Toasts
&lt;/h2&gt;

&lt;p&gt;So far, we have covered &lt;strong&gt;Field-level errors&lt;/strong&gt; - issues like a typo in an email that should be displayed directly under the input field.&lt;/p&gt;

&lt;p&gt;But what about &lt;strong&gt;Process-level events&lt;/strong&gt;? If the database connection drops unexpectedly, or if the user successfully completes the subscription flow, we need to provide global feedback. In modern UI design, this is usually handled by a Toast notification library (like Sonner).&lt;/p&gt;

&lt;p&gt;Because our entire architecture speaks in strict domain codes, integrating localized toasts is incredibly clean. We don't want our &lt;code&gt;useSubscribeNewsletter&lt;/code&gt; hook to import translation libraries or know about UI components. Instead, we use the Inversion of Control principle and pass simple callbacks.&lt;/p&gt;

&lt;p&gt;Let's update our orchestrator hook to accept success and error callbacks:&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;// src/hooks/useSubscribeNewsletter.ts (Updated)&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:actions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;COMMON_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isErrorMessageCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CommonMessageCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ServerErrorCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/messages&lt;/span&gt;&lt;span class="dl"&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;useSubscribeNewsletter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;locale&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;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CommonMessageCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;
    &lt;span class="nx"&gt;onServerError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ServerErrorCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&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="c1"&gt;// ... state initialization&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subscribeAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... validation logic&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// ... action call&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="c1"&gt;// Server / network / unexpected errors&lt;/span&gt;
      &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onServerError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INTERNAL_SERVER_ERROR&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segmentationAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... validation logic&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&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;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newsLetter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="cm"&gt;/*...*/&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;

      &lt;span class="c1"&gt;// ... error handling&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;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setActionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;setStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Trigger the success callback with a strict domain code&lt;/span&gt;
        &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;COMMON_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUBSCRIPTION_SUCCEED&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onServerError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ERROR_MESSAGE_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INTERNAL_SERVER_ERROR&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* ... */&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;Now, back in our &lt;code&gt;NewsletterFlow&lt;/code&gt; component (our Island wrapper), we provide those callbacks. Since the wrapper already received the lightweight JSON dictionary via props during SSR, it can instantly translate the domain code and fire the toast notification.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/islands/NewsletterFlow.tsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toast&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sonner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useSubscribeNewsletter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/hooks/useSubscribeNewsletter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ServerErrorCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CommonMessageCode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NewsletterFlow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="nx"&gt;subscribeAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;segmentationAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSubscribeNewsletter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// We receive the domain code and map it directly to our dictionary&lt;/span&gt;
    &lt;span class="na"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CommonMessageCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;

    &lt;span class="na"&gt;onServerError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ServerErrorCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="c1"&gt;// ... render logic&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Note: In a future deep-dive, we will explore how to optimize these interactive islands even further using custom &lt;code&gt;client:interaction&lt;/code&gt; directives in Astro to delay loading the Toast library until it's actually needed. But for now, standard hydration works perfectly).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And there you have it. A complete, end-to-end interactive flow that handles complex Zod validation, server-side Astro Actions, lazy-loaded components, and global toast notifications - all fully localized, fully type-safe, and without shipping a single megabyte of translation engines to the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  API Routes, Webhooks &amp;amp; Internal Microservices
&lt;/h2&gt;

&lt;p&gt;Throughout this article, we've relied heavily on &lt;strong&gt;Astro Actions&lt;/strong&gt; for frontend-to-backend communication. In my practice, Actions are the undisputed king for UI interactions because they provide end-to-end type safety out of the box.&lt;/p&gt;

&lt;p&gt;I use standard &lt;strong&gt;Astro API Routes&lt;/strong&gt; (&lt;code&gt;src/pages/api/&lt;/code&gt;) almost exclusively for external integrations: payment webhooks (Stripe, Paddle, LemonSqueezy), 3rd-party callbacks, or Telegram bot endpoints.&lt;/p&gt;

&lt;p&gt;But as your SaaS scales, you will likely offload heavy background tasks to separate, internal Cloudflare Workers via &lt;strong&gt;Service Bindings&lt;/strong&gt; (which allow workers to communicate with zero network latency).&lt;/p&gt;

&lt;p&gt;Whether your boundary is an Astro Action serving a React form, or an Astro API Route serving a Telegram bot webhook, the architectural rule remains identical: &lt;strong&gt;Localization at the Boundary&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Imagine you have a Telegram Bot API Route that processes a subscription via an internal &lt;code&gt;Billing Worker&lt;/code&gt;. Should that internal worker know the user's language or import dictionaries?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absolutely not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Internal microservices and domain logic must remain strictly language-agnostic. They communicate exclusively via machine-readable domain codes (&lt;code&gt;"INSUFFICIENT_FUNDS"&lt;/code&gt;). It is the responsibility of the API Route (the absolute boundary facing the external world) to intercept this code and translate it right before responding:&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;// src/pages/api/webhooks/telegram.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fetchTranslations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/i18n/fetcher&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;payload&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Identify the external user's preferred language (e.g., 'es')&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userLang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;language_code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Call internal language-agnostic service (e.g., via Cloudflare Service Binding)&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BILLING_SERVICE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chargeUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// result.error is a strict domain code like "INSUFFICIENT_FUNDS"&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Fetch the dictionary specifically for this external user&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchTranslations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;userLang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;messages&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="c1"&gt;// 4. Translate at the boundary&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billing&lt;/span&gt;&lt;span class="p"&gt;[&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;error&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INTERNAL_SERVER_ERROR&lt;/span&gt;

    &lt;span class="c1"&gt;// Send localized response back to Telegram API&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendTelegramReply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By pushing localization to the extreme edges of your architecture (React Islands for the UI, and API Routes for external consumers), your internal services remain lightweight, highly cacheable, and infinitely easier to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudflare D1 &amp;amp; Drizzle ORM Localization (UGC)
&lt;/h2&gt;

&lt;p&gt;The final boss of internationalization is dynamic data. Translating static UI strings like "Submit" is simple, but what about data created by your users? If you are building a multi-tenant SaaS, your users might create product categories or pricing tiers that need to be localized.&lt;/p&gt;

&lt;p&gt;How do you store this in Cloudflare D1 using Drizzle ORM? Let's look at the trade-offs of the three standard approaches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F89ggy1ah9ojvhbmuuyzz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F89ggy1ah9ojvhbmuuyzz.jpg" alt="Comparison of database localization patterns in Cloudflare D1: Wide Table anti-pattern vs. Relational Translation Tables vs. highly scalable Drizzle ORM JSON columns for Edge performance" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Anti-Pattern: The "Wide Table"
&lt;/h3&gt;

&lt;p&gt;The most common beginner mistake is adding language-specific columns to the main table:&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;// ❌ The Wide Table Anti-Pattern&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sqliteTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;title_en&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title_en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;title_es&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title_es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;title_de&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title_de&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an architectural dead end. Every time marketing asks to support a new language (e.g., French), you have to run a database migration (&lt;code&gt;ALTER TABLE&lt;/code&gt;), update your Drizzle schema, and redeploy the backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Enterprise Pattern: Translation Tables
&lt;/h3&gt;

&lt;p&gt;The strict relational approach is to separate the core entity from its translations using a one-to-many relationship.&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;// ✅ The Relational Pattern&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sqliteTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// Language-agnostic data&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;const&lt;/span&gt; &lt;span class="nx"&gt;productTranslations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sqliteTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product_translations&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;references&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;locale&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;locale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// 'en', 'es', 'de'&lt;/span&gt;
  &lt;span class="na"&gt;title&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&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;&lt;strong&gt;Pros:&lt;/strong&gt; Infinite scalability. Adding a new language is just inserting a new row, not modifying the schema.&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; It requires &lt;code&gt;JOIN&lt;/code&gt;s for every read query.&lt;/p&gt;

&lt;p&gt;To implement &lt;strong&gt;Graceful Fallback&lt;/strong&gt; (the "Split-Brain" logic we discussed in Part 1) in pure SQL, you would perform a double &lt;code&gt;LEFT JOIN&lt;/code&gt; - once for the requested &lt;code&gt;uiLocale&lt;/code&gt;, and once for the default fallback locale (e.g., &lt;code&gt;'en'&lt;/code&gt;). You then use &lt;code&gt;COALESCE(es.title, en.title)&lt;/code&gt; to let the database automatically decide which string to return.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. The Modern Edge Pattern: JSON Columns
&lt;/h3&gt;

&lt;p&gt;Because Cloudflare D1 is built on SQLite, it has fantastic (and blazingly fast) support for JSON functions. For read-heavy Edge applications, we can leverage this to avoid &lt;code&gt;JOIN&lt;/code&gt;s entirely.&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;// 🚀 The Edge Pattern (NoSQL in SQL)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sqliteTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="c1"&gt;// Drizzle handles the JSON parsing automatically&lt;/span&gt;
  &lt;span class="na"&gt;translations&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;translations&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="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;$type&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;
    &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&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="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&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 stored JSON looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Shoes"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"es"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Zapatos"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this wins on the Edge:&lt;/strong&gt; You retrieve the entire entity with a single, fast D1 read. There are no complex SQL joins. The Graceful Fallback logic is handled cleanly in your TypeScript &lt;code&gt;DomainContext&lt;/code&gt;:&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;// Handled cleanly in the Domain Services layer&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localizedTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;uiLocale&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For most SaaS use cases on Cloudflare Workers, this JSON-column approach hits the perfect sweet spot between developer experience, database performance, and schema flexibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Internationalization is rarely a feature you can just "bolt on" at the end of a project. When you treat translations as massive JavaScript bundles that must be downloaded, parsed, and executed by the client's browser, you are fundamentally crippling your application's performance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd36v3lnh20jl7prxudkx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd36v3lnh20jl7prxudkx.jpg" alt="Architectural diagram showing 'Localization at the Boundary', separating pure language-agnostic domain logic (Zod, Drizzle, Cloudflare Workers) from UI translation layers (React Islands, Astro API routes)" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By inverting the control - by treating errors as strict domain codes, resolving languages in Astro Middleware, and isolating translations to the absolute Edge of your architecture - you achieve something rare. You get a fully localized, type-safe, complex interactive React application that still ships with a &lt;strong&gt;Zero-JS localization payload&lt;/strong&gt; and perfect Core Web Vitals.&lt;/p&gt;

&lt;p&gt;This isn't just theory. This is exactly how we built EdgeKits.&lt;/p&gt;




&lt;p&gt;The continuation is here: how to completely separate translation from code deployment on Cloudflare Workers. Per-namespace cache keys and the Purge API for granular, instant i18n updates.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/garyedgekits/stop-redeploying-to-update-translations-granular-edge-cache-invalidation-with-cloudflare-purge-api-2cm7"&gt;Read Part 3: Stop Redeploying to Update Translations here&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get the Code &amp;amp; Stay Updated&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You don't have to build the foundation from scratch. While the advanced Zod mapping, state-machine orchestrators, and &lt;code&gt;DomainContext&lt;/code&gt; patterns we discussed today are specific to our production application, the underlying &lt;strong&gt;Edge-native i18n architecture&lt;/strong&gt; - including the Astro middleware, caching logic, and Split-Brain fallback - is available in our open-source starter kit.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Star the Repo to support the project:&lt;/strong&gt; &lt;a href="https://github.com/EdgeKits/astro-edgekits-core" rel="noopener noreferrer"&gt;Astro EdgeKits Core&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>astro</category>
      <category>cloudflare</category>
      <category>react</category>
    </item>
    <item>
      <title>Stop Shipping Translations to the Client: Edge-Native i18n with Astro &amp; Cloudflare (Part 1)</title>
      <dc:creator>Gary Stupak</dc:creator>
      <pubDate>Wed, 25 Feb 2026 06:41:50 +0000</pubDate>
      <link>https://forem.com/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-1-5b38</link>
      <guid>https://forem.com/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-1-5b38</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmh1tofsuv10i5iggjizi.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmh1tofsuv10i5iggjizi.jpg" alt="Conceptual illustration of a spaceship jettisoning heavy JSON translations and client-side bloat, representing the shift to zero-JS Edge-Native i18n architecture." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When I started building EdgeKits.dev, the stack felt like a cheat code for 2026.&lt;/p&gt;

&lt;p&gt;Astro on the frontend. Cloudflare Workers on the backend. All-in on the Edge. It promised and delivered incredible TTFB, out-of-the-box SEO, and cheap scalability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the magic broke.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I hit the wall of Astro Internationalization (i18n). It should have been trivial: take a set of JSON files (&lt;code&gt;en.json&lt;/code&gt;, &lt;code&gt;de.json&lt;/code&gt;) and show the user the right text. But when I surveyed the standard ecosystem - from established tools like &lt;code&gt;astro-i18next&lt;/code&gt; to modern solutions like &lt;code&gt;Paraglide JS&lt;/code&gt; - I realized they all carried architectural baggage that I couldn't justify shipping in an environment where every byte and every millisecond counts.&lt;/p&gt;

&lt;p&gt;In this deep dive, we'll build a completely Zero-JS, Edge-Native i18n architecture. I will show you how to move your routing logic to Astro Middleware, store translation dictionaries in Cloudflare KV, and render localized React Islands without shipping a single byte of JSON to the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the "Perfect Stack" Cracked
&lt;/h2&gt;

&lt;p&gt;In the SPA world, we accept a lazy pattern: the client loads, detects the browser language, fetches a 50KB translation file, and &lt;em&gt;then&lt;/em&gt; the interface makes sense. But in the world of Astro and Island Architecture, this approach starts to feel like an architectural atavism.&lt;/p&gt;

&lt;p&gt;I tried fitting standard solutions into the constraints of Cloudflare Workers and hit three fundamental walls.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhm4v9poxr331x6f705km.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhm4v9poxr331x6f705km.jpg" alt="Comparison of traditional client-side JSON bundle bloat versus Edge-Native pre-rendered HTML in Astro" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The "Fat Worker" Problem (Bundle Bloat)
&lt;/h3&gt;

&lt;p&gt;Most libraries want you to import JSON files directly into your code. Fine for a static site. &lt;strong&gt;May be critical for a Worker&lt;/strong&gt;. On Cloudflare, every byte of text becomes part of your JavaScript bundle. With a strict 3MB limit on the free tier (and 10MB on paid), "baking" translations into the Worker means stealing space from business logic. It increases cold start times.&lt;/p&gt;

&lt;p&gt;I didn't want adding a new language to slow down my entire API.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Hydration Hell
&lt;/h3&gt;

&lt;p&gt;This is the classic Astro + React conflict. The Server (SSR) renders English because the URL says so. The Client (React Island) wakes up, checks &lt;code&gt;localStorage&lt;/code&gt;, sees "German," and panic-renders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result:&lt;/strong&gt; A flickering UI, a console screaming about hydration mismatches, and a "broken app" feel. Trying to sync state via third-party stores (like Nano Stores) worked, but required writing boilerplate for every single button.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. CLS and the "Jump"
&lt;/h3&gt;

&lt;p&gt;If we decide not to bundle JSON but fetch it client-side (the old SPA way), we kill our Web Vitals. Users see empty space or raw translation keys while the JSON flies over the wire. For a project obsessed with performance, this was unacceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Paradigm Shift: Translations are Data, Not Code
&lt;/h2&gt;

&lt;p&gt;Take Paraglide JS, for example. Its compiler and tree-shaking are brilliant. It solves the client-side bloat perfectly. But as I mapped out the architecture for a growing SaaS, I realized it introduced a set of invisible taxes I wasn't willing to pay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The "Fat Worker" Paradox&lt;/strong&gt;&lt;br&gt;
Tree-shaking is great for the browser, but it simply moves the weight to the Server. Paraglide compiles translations into code. To render SSR, the Worker must load all that code into memory. This is the trap.&lt;/p&gt;

&lt;p&gt;On Cloudflare, you have a hard limit on script size (3MB Free / 10MB Paid). "Baking" encyclopedias of text into your executable binary is an anti-pattern. I didn't want my deployment to fail - or my cold starts to spike - just because I added a German translation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The Dynamic Content Gap&lt;/strong&gt;&lt;br&gt;
Static tools only solve half the problem. Paraglide handles your "Save" button, but it ignores your database. My SaaS runs on Cloudflare D1. How do I translate user-generated content? How do I run SQL LIKE queries on compiled functions? I was staring at a future where I had to maintain two separate i18n stacks: one for the UI (compiled code) and one for the Data (DB).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. High-Complexity Maintenance&lt;/strong&gt;&lt;br&gt;
Finally, it trades Latency for Fragility. By adopting a compiler-based approach, you marry your build pipeline to a specific tool. If the workerd runtime updates and the compiler lags, your build breaks. And despite the tooling, it doesn't actually prevent hydration mismatches - if you forget to pass a prop or initialize a store correctly on the client, the UI still flickers.&lt;/p&gt;

&lt;p&gt;I needed something else. I wanted i18n to behave like a Content Delivery Service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Edge is the Source of Truth:&lt;/strong&gt; It decides the language based on URL, cookies, and headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Client is "Dumb":&lt;/strong&gt; It receives ready-to-render data. No guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-JS Payload:&lt;/strong&gt; Translations are injected into HTML or component props during SSR.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I needed a system that keeps translations close to the user (Cloudflare KV), caches them at the edge (Cache API), and feeds them to Astro components without bloating the Worker bundle. I couldn't find a solution that met these requirements while maintaining full Type-Safety.&lt;/p&gt;

&lt;p&gt;That left me with only one option: build a bespoke architecture from scratch.&lt;/p&gt;
&lt;h2&gt;
  
  
  Edge-Native i18n Architecture: Inverting Control
&lt;/h2&gt;

&lt;p&gt;In a traditional SPA, the client is the boss. It loads, checks &lt;code&gt;navigator.language&lt;/code&gt;, and issues a network request for a translation file. This is a &lt;strong&gt;"Pull" architecture&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I flipped this model. In EdgeKits, the Client is dumb. It does not guess the language - and it certainly doesn't fetch it over the network. It receives the language as a constraint from the Server.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;"Push" architecture&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Request Flow
&lt;/h3&gt;

&lt;p&gt;Everything happens before the first byte of HTML is flushed to the browser. We moved the "Router" logic entirely into Cloudflare Workers via Astro Middleware.&lt;/p&gt;

&lt;p&gt;Here is the lifecycle of a request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Interception:&lt;/strong&gt; The request hits the Cloudflare Worker.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Resolution:&lt;/strong&gt; Our Middleware analyzes the request immediately - checking URL paths (&lt;code&gt;/de/&lt;/code&gt;), cookies, and headers.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Data Fetch:&lt;/strong&gt; The Worker checks the &lt;strong&gt;Edge Cache&lt;/strong&gt;. If it's a miss, it fetches from &lt;strong&gt;KV&lt;/strong&gt; and hydrates the cache.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Injection:&lt;/strong&gt; Translations are injected directly into Astro props.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Rendering:&lt;/strong&gt; Astro generates HTML with strings baked in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqpvuvnn32g7ihkkq0uba.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqpvuvnn32g7ihkkq0uba.jpg" alt="Astro Middleware request pipeline showing uiLocale detection and translationLocale normalization" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By the time the React Island wakes up on the client, the text is already there. No &lt;code&gt;useEffect&lt;/code&gt;. No loading spinners. The component hydrates over HTML that matches its props exactly.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Core Logic: Decoupling Intent from Data
&lt;/h3&gt;

&lt;p&gt;The critical architectural decision here was to split the concept of "Current Locale" into two distinct variables.&lt;/p&gt;

&lt;p&gt;Most i18n frameworks tightly couple the &lt;strong&gt;URL&lt;/strong&gt; to the &lt;strong&gt;Data&lt;/strong&gt;. If a user visits &lt;code&gt;/ja/&lt;/code&gt; (Japanese) but you haven't deployed the translation files yet, standard adapters usually force a &lt;strong&gt;302 Redirect&lt;/strong&gt; back to English. This changes the URL and disrupts the user's intent.&lt;/p&gt;

&lt;p&gt;Worse, if the server falls back to English but the client-side router initializes with &lt;code&gt;locale='ja'&lt;/code&gt; (derived from the URL), you trigger a &lt;strong&gt;Hydration Mismatch&lt;/strong&gt;. The server sends English HTML, but the client expects Japanese logic, causing the UI to flicker or reset.&lt;/p&gt;

&lt;p&gt;I introduced a "Split Brain" model in the request context to prevent this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjwf39gzird0v638vj318.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjwf39gzird0v638vj318.jpg" alt="Split Brain architecture decoupling user intent from data availability to prevent runtime crashes" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;uiLocale&lt;/code&gt; (The Intent):&lt;/strong&gt; What the user &lt;em&gt;wants&lt;/em&gt; to see. This controls the URL (&lt;code&gt;/ja/about&lt;/code&gt;), the &lt;code&gt;&amp;lt;html lang="ja"&amp;gt;&lt;/code&gt; tag, and SEO metadata.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;translationLocale&lt;/code&gt; (The Data):&lt;/strong&gt; What we can &lt;em&gt;actually&lt;/em&gt; show. This controls the dictionary loaded from KV.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt;&lt;br&gt;
If a user visits &lt;code&gt;/ja/about&lt;/code&gt; but we haven't translated the marketing page into Japanese yet, the system doesn't redirect.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;uiLocale&lt;/code&gt; remains &lt;code&gt;"ja"&lt;/code&gt; (preserving the URL and user preference).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;translationLocale&lt;/code&gt; gracefully falls back to &lt;code&gt;"en"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site never breaks with &lt;code&gt;undefined is not a function&lt;/code&gt;. The user sees the interface in English, but the app structure remains stable. This is &lt;strong&gt;Graceful Degradation&lt;/strong&gt; baked into the core.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cloudflare KV Data Layer: Solving the "Fat Worker"
&lt;/h2&gt;

&lt;p&gt;The standard advice for i18n is simple: "Just import your JSON files." For a static site, that works. For a Serverless application, it is an architectural trap.&lt;/p&gt;

&lt;p&gt;On Cloudflare, your code and your assets compete for the same resources. The Worker script size limit is strict - 3MB on the Free plan and 10MB on Paid.&lt;/p&gt;

&lt;p&gt;If you "bake" your translations into the JavaScript bundle, you are stealing space from your business logic. Every time you add a new language or a new blog post translation, your Worker gets fatter. Your cold starts get slower. And eventually, you hit the wall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I refused to ship text as code.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The Solution: KV as the Source of Truth
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;I moved&lt;/strong&gt; the translation dictionaries out of the &lt;code&gt;_worker.js&lt;/code&gt; bundle and into &lt;strong&gt;Cloudflare KV&lt;/strong&gt;. In this architecture, translations are treated strictly as external data. They are stored with keys like: &lt;code&gt;edgekits:landing:en&lt;/code&gt;, &lt;code&gt;edgekits:common:de&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This decouples the deployment of code from the deployment of content. You can fix a typo in the German pricing page without redeploying the entire application backend.&lt;/p&gt;
&lt;h3&gt;
  
  
  Edge Caching: The Cache API "Secret Sauce"
&lt;/h3&gt;

&lt;p&gt;KV is fast, but it is not instant. It requires a sub-request. It also costs money - the Free tier caps you at 100,000 reads per day. For a high-traffic application, hitting KV on every single request is a non-starter.&lt;/p&gt;

&lt;p&gt;To solve this, &lt;strong&gt;the architecture places&lt;/strong&gt; the &lt;strong&gt;Cache API&lt;/strong&gt; (&lt;code&gt;caches.default&lt;/code&gt;) in front of KV.&lt;/p&gt;

&lt;p&gt;When a request comes in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; The Worker checks the Edge Cache for &lt;code&gt;edgekits:landing:en&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Hit:&lt;/strong&gt; It serves instantly (sub-millisecond latency).&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Miss:&lt;/strong&gt; It fetches from KV, constructs the response, and puts it into the Cache with a &lt;code&gt;stale-while-revalidate&lt;/code&gt; directive.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpzg51rvtltzb6fxtdu9s.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpzg51rvtltzb6fxtdu9s.jpg" alt="Flowchart demonstrating Edge Cache API intercepting requests before hitting Cloudflare KV" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Economic Logic:&lt;/strong&gt; I accept the latency cost (and the KV bill) on the 1st request to buy 0ms latency and zero KV read costs for the &lt;strong&gt;next 10,000 requests&lt;/strong&gt;. This allows the system to serve millions of users while staying comfortably within the limits of the Free tier.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Trade-off: HTML Payload Size
&lt;/h3&gt;

&lt;p&gt;There is no free lunch in engineering. By removing the translations from the JavaScript bundle (Zero-JS), &lt;strong&gt;I effectively moved&lt;/strong&gt; that weight into the HTML document.&lt;/p&gt;

&lt;p&gt;Since the Client is "dumb" and doesn't fetch JSON, the server must inject the translation data directly into the DOM (usually via props or a script tag) so the React components can hydrate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Risk:&lt;/strong&gt; If you load a massive 50KB JSON file for a page that only displays "Hello World", your initial HTML download size bloats. This can hurt your Time to First Byte (TTFB).&lt;/p&gt;
&lt;h3&gt;
  
  
  Pro Tip: Namespace Splitting
&lt;/h3&gt;

&lt;p&gt;To mitigate the payload risk, adoption of &lt;strong&gt;Namespace Splitting&lt;/strong&gt; is mandatory. Do not dump every string into a single global &lt;code&gt;common.json&lt;/code&gt;. That is a lazy pattern inherited from the SPA era.&lt;/p&gt;

&lt;p&gt;Instead, break your translations into granular domains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;buttons.json&lt;/code&gt; (Global UI elements)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;landing.json&lt;/code&gt; (Landing page only)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pricing.json&lt;/code&gt; (Pricing page only)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dashboard.json&lt;/code&gt; (App only)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In EdgeKits, the &lt;code&gt;fetchTranslations&lt;/code&gt; function accepts an array of namespaces. On the Landing Page, &lt;strong&gt;I only load&lt;/strong&gt; &lt;code&gt;['common', 'hero']&lt;/code&gt;. The heavy &lt;code&gt;dashboard&lt;/code&gt; strings are never fetched from KV and never injected into the HTML. This keeps the initial document lightweight while ensuring the client has exactly - and only - what it needs to render.&lt;/p&gt;
&lt;h2&gt;
  
  
  Astro Middleware: The i18n Routing Controller
&lt;/h2&gt;

&lt;p&gt;In a standard Astro app, you might be tempted to check the locale inside your &lt;code&gt;.astro&lt;/code&gt; pages or layout files.&lt;br&gt;
&lt;strong&gt;Don't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you calculate the locale in a Layout, you have already executed too much code. You need to know the language &lt;em&gt;before&lt;/em&gt; you render a single component.&lt;/p&gt;

&lt;p&gt;I moved this logic entirely into &lt;code&gt;src/domain/i18n/middleware/i18n.ts&lt;/code&gt;. This file acts as the "Air Traffic Controller" for the application. It runs on the Edge, intercepts every request, and determines the &lt;code&gt;uiLocale&lt;/code&gt; before Astro even boots up the page rendering process.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Detection Hierarchy
&lt;/h3&gt;

&lt;p&gt;Here, a hierarchy of authority for determining the user's language naturally presents itself, where the user's explicit intent always takes precedence over implicit signals.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Finwoo5olsoh7874zkd29.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Finwoo5olsoh7874zkd29.jpg" alt="Locale detection hierarchy pyramid showing URL, Cookie, Accept-Language header, and Geo-IP prioritization in Astro middleware" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;URL (The King):&lt;/strong&gt; If the path is &lt;code&gt;/es/about&lt;/code&gt;, the user wants Spanish. Period. This is the primary source of truth.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Cookie (The Override):&lt;/strong&gt; If the user is at the root &lt;code&gt;/&lt;/code&gt; (where no language is specified) but has a &lt;code&gt;locale&lt;/code&gt; cookie, I respect that preference.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Browser Header (Astro Native):&lt;/strong&gt; If no URL prefix and no cookie exist, I leverage Astro's built-in &lt;code&gt;context.preferredLocale&lt;/code&gt; to handle the standard &lt;code&gt;Accept-Language&lt;/code&gt; negotiation automatically.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Geo-IP (The Safety Net):&lt;/strong&gt; If all else fails, I use the Cloudflare &lt;code&gt;request.cf.country&lt;/code&gt; property to make a best-guess based on location.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  The Implementation
&lt;/h3&gt;

&lt;p&gt;Here is the middleware that orchestrates this. It handles the “Soft 404” problem, keeps the Cookie in sync with the URL, and does all the heavy lifting required for seamless i18n routing:&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;// src/domain/i18n/middleware/i18n.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MiddlewareHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;LocaleSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/i18n/schema&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_LOCALE&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/i18n/constants&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getCookieLang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCookieLang&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/i18n/cookie-storage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mapCountryToLocale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/i18n/country-to-locale-map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resolveLocaleForTranslations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/i18n/resolve-locale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PUBLIC_FILE_REGEX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;ico|png|jpg|jpeg|svg|webp|gif|css|js|map|txt|xml|json|woff2&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;|avif&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IGNORED_PREFIXES&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="s1"&gt;/api&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="s1"&gt;/assets&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="s1"&gt;/_astro&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="s1"&gt;/_image&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="s1"&gt;/_actions&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="s1"&gt;/favicon&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;I18nMiddlewareContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Parameters&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MiddlewareHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shouldBypassI18n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&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;boolean&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;PUBLIC_FILE_REGEX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;IGNORED_PREFIXES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applySecurityHeaders&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;Response&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildLocalizedPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rest&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="kr"&gt;string&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;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&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;suffix&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveFallbackLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;I18nMiddlewareContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Locale&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;cookieLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieLang&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&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;cookieLocale&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;cookieLocale&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browserRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preferredLocale&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;browserRaw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LocaleSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;browserRaw&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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;short&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browserRaw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LocaleSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;short&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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;geoLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapCountryToLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LocaleSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;geoLocale&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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;DEFAULT_LOCALE&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;const&lt;/span&gt; &lt;span class="nx"&gt;i18nMiddleware&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MiddlewareHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&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;firstSegment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;safeLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_LOCALE&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;firstSegment&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;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LocaleSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;firstSegment&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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;safeLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uiLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;safeLocale&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translationLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolveLocaleForTranslations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safeLocale&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="nf"&gt;shouldBypassI18n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&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;contentType&lt;/span&gt; &lt;span class="o"&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isHtml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&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;isRedirect&lt;/span&gt; &lt;span class="o"&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&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;isHtml&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isRedirect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not found&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&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="s1"&gt;text/plain; charset=utf-8&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;return&lt;/span&gt; &lt;span class="nx"&gt;response&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;fallbackLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolveFallbackLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;firstSegment&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;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildLocalizedPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fallbackLocale&lt;/span&gt;&lt;span class="p"&gt;,&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;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;applySecurityHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;applySecurityHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&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;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LocaleSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;firstSegment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;urlLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;urlLocale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCookieLang&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;urlLocale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uiLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;urlLocale&lt;/span&gt;
    &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translationLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolveLocaleForTranslations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urlLocale&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;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildLocalizedPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urlLocale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;applySecurityHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;applySecurityHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&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;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildLocalizedPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fallbackLocale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segments&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;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;applySecurityHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;applySecurityHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&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;This function resolves the actual locale we need to request from KV. It gracefully falls back to a default if a translation bundle for the current UI locale is missing:&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;// src/domain/i18n/resolve-locale.ts&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;resolveLocaleForTranslations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;hasTranslations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_LOCALE&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Edge Capability: Geo-IP Fallback
&lt;/h3&gt;

&lt;p&gt;You might notice the &lt;code&gt;mapCountryToLocale&lt;/code&gt; helper in the fallback logic. This is where we leverage the Edge platform.&lt;/p&gt;

&lt;p&gt;Cloudflare exposes the visitor's country code in every request. Here is a simple, O(1) lookup map to convert codes like &lt;code&gt;DE&lt;/code&gt; (Germany) or &lt;code&gt;BR&lt;/code&gt; (Brazil) into supported locales.&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;// src/domain/i18n/country-to-locale-map.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./schema.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GEO_MAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;Locale&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// --- ANGLOSPHERE ---&lt;/span&gt;
  &lt;span class="na"&gt;US&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
  &lt;span class="na"&gt;GB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
  &lt;span class="c1"&gt;// --- DACH ---&lt;/span&gt;
  &lt;span class="na"&gt;DE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// --- LATAM + SPAIN ---&lt;/span&gt;
  &lt;span class="na"&gt;ES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Asia&lt;/span&gt;
  &lt;span class="na"&gt;JP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ja&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mapCountryToLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;GEO_MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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;h3&gt;
  
  
  Why This Design?
&lt;/h3&gt;

&lt;p&gt;This middleware establishes &lt;code&gt;context.locals.uiLocale&lt;/code&gt; as the single source of truth.&lt;/p&gt;

&lt;p&gt;The React components don't check &lt;code&gt;localStorage&lt;/code&gt;. The Layout doesn't parse the URL. They simply read &lt;code&gt;uiLocale&lt;/code&gt; from the context. By treating the URL as the strict authority for state, we eliminate the possibility of a "Split Brain" scenario where the URL says English but the Interface renders German.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Dumb" Client: Type-Safe i18n for Astro Islands
&lt;/h2&gt;

&lt;p&gt;Astro is famous for shipping "Zero JS" by default. But in the real world, you eventually need interactivity: a Newsletter form, a Pricing toggle, or a User Dashboard. In Astro, these isolated bits of interactivity are called &lt;strong&gt;"Islands"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When a React Island wakes up (hydrates), it often realizes: &lt;em&gt;"Wait, I need text!"&lt;/em&gt;. The standard SPA reflex is to fire a hook like &lt;code&gt;useTranslation&lt;/code&gt;, which triggers a network request for a JSON file, shows a loading spinner, and finally causes a Layout Shift (CLS).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "Standard" React Way (Anti-Pattern for Edge):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Bad: Triggers network fetch + Re-render&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ready&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTranslation&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Spinner&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;welcome_message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In EdgeKits, we treat the Client as &lt;strong&gt;"dumb"&lt;/strong&gt;. It does not know how to fetch translations. It does not know which language is active. It simply receives data via props from the Astro Page Controller.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mechanism: Strict Prop Drilling
&lt;/h3&gt;

&lt;p&gt;We moved the complexity from the Components to the Server. The page fetches the specific namespaces it needs from the Edge Cache and passes them down to the Island as a simple JSON object.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The EdgeKits Way:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/layout/Hero.tsx&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;HeroProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Strict Type Safety: We know EXACTLY what 'hero' contains&lt;/span&gt;
  &lt;span class="nl"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;I18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;landing&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="s1"&gt;hero&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Hero&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;HeroProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 2. No hooks. No generic strings. Just data.&lt;/span&gt;
  &lt;span class="c1"&gt;// 3. Renders instantly. Zero CLS.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This results in &lt;strong&gt;Zero CLS&lt;/strong&gt;. The HTML arrives at the browser with the text already inside the tags.&lt;/p&gt;

&lt;h3&gt;
  
  
  Type-Safety: "It compiles, therefore it works"
&lt;/h3&gt;

&lt;p&gt;One of the biggest risks in i18n is "key drift"—when your code asks for &lt;code&gt;t.description&lt;/code&gt; but the JSON file has &lt;code&gt;t.desc&lt;/code&gt;. I refused to use &lt;code&gt;any&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;EdgeKits includes a generator script (&lt;code&gt;npm run i18n:bundle&lt;/code&gt;) that scans your &lt;code&gt;src/locales&lt;/code&gt; directory and generates a strict TypeScript definition file (&lt;code&gt;I18n.Schema&lt;/code&gt;).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you delete a key in &lt;code&gt;en/common.json&lt;/code&gt;, the build fails.&lt;/li&gt;
&lt;li&gt;If you mistype a prop name, the build fails.&lt;/li&gt;
&lt;li&gt;You get autocomplete for every single string in your project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This turns internationalization from a runtime guessing game into a compile-time guarantee.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78pgqeb691xfopib9qqg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78pgqeb691xfopib9qqg.jpg" alt="VS Code autocomplete demonstrating end-to-end type safety for i18n dictionaries in Astro" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Safe Interpolation: The &lt;code&gt;fmt()&lt;/code&gt; Helper
&lt;/h3&gt;

&lt;p&gt;Raw JSON is static, but UI is dynamic. We often need to inject variables like &lt;code&gt;"Hello, {name}!"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Shipping a heavy interpolation engine like &lt;code&gt;intl-messageformat&lt;/code&gt; to the client defeats the purpose of keeping the bundle small. Instead, I wrote a lightweight, runtime-agnostic helper called &lt;code&gt;fmt()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;locales/en/common.json&lt;/code&gt;:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"welcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome back, &amp;lt;strong&amp;gt;{name}&amp;lt;/strong&amp;gt;!"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;&lt;code&gt;src/components/common/Welcome.tsx&lt;/code&gt;:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fmt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/i18n/format&lt;/span&gt;&lt;span class="dl"&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;Welcome&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userName&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 'fmt' escapes 'userName' to prevent XSS,&lt;/span&gt;
  &lt;span class="c1"&gt;// but preserves the &amp;lt;strong&amp;gt; tag from the JSON.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;welcome&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userName&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Localizing React Islands in Astro MDX (The "Final Boss")
&lt;/h2&gt;

&lt;p&gt;Using React components inside Markdown (MDX) is easy. Using &lt;em&gt;internationalized&lt;/em&gt; components inside Markdown is a nightmare because MDX doesn't have access to &lt;code&gt;Astro.locals&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Wrapper Pattern: SSR Prop Injection in Astro
&lt;/h3&gt;

&lt;p&gt;We solve this by treating the Astro component as a "Data Controller" and the React component as a "Pure View". &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwtdmh8cc3vwafe5io84o.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwtdmh8cc3vwafe5io84o.jpg" alt="The Wrapper Pattern showing Astro server fetch injecting props into a pure React UI island" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of making the React component fetch its own translations, we create a thin &lt;code&gt;.astro&lt;/code&gt; wrapper that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Runs on the server.&lt;/li&gt;
&lt;li&gt; Accesses &lt;code&gt;Astro.locals.translationLocale&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; Fetches the specific translation namespace from KV (or Cache).&lt;/li&gt;
&lt;li&gt; Passes the data as typed props to the React component.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  1. The React Component (Pure &amp;amp; Dumb)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/blog/islands/LocalizedCounter.tsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pluralIcu&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/domain/i18n/format&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LocalizedCounterProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;I18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;counter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;locale&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LocalizedCounter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;LocalizedCounterProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initial&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;formattedLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pluralIcu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;patterns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;formattedLabel&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;increment&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&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;h4&gt;
  
  
  2. The Astro Wrapper (The Bridge)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/components/blog/LocalizedCounterWrapper.astro

import { LocalizedCounter } from '@/components/blog/islands/LocalizedCounter'
import { fetchTranslations } from '@/domain/i18n/fetcher'

const { translationLocale, runtime } = Astro.locals
const { blog } = await fetchTranslations(runtime, translationLocale, ['blog'])
const t = blog.counter
---

&amp;lt;LocalizedCounter
  client:visible
  t={t}
  locale={translationLocale}
  labels={blog.counter}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  3. Usage in MDX
&lt;/h4&gt;

&lt;p&gt;Now we simply inject our island component wrapper into the &lt;code&gt;components&lt;/code&gt; prop in our dynamic route, and use &lt;code&gt;&amp;lt;LocalizedCounter /&amp;gt;&lt;/code&gt; directly in our &lt;code&gt;.mdx&lt;/code&gt; files. The specific strings needed for the counter are "baked" into the component props during the server render.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resilience &amp;amp; Tooling: Production Grade
&lt;/h2&gt;

&lt;p&gt;If Cloudflare KV is slow or returns an error, we cannot show a blank page.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Safety Net: Compiled Fallbacks
&lt;/h3&gt;

&lt;p&gt;We implemented a "Belt and Suspenders" approach.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;The Belt (Cloudflare KV):&lt;/strong&gt; Stores all translations. Dynamic.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The Suspenders (Compiled Fallbacks):&lt;/strong&gt; We compile the &lt;em&gt;Default Locale&lt;/em&gt; (e.g., English) directly into the Worker bundle as a JavaScript object.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;br&gt;
When the middleware requests translations, it performs a &lt;code&gt;deepMerge&lt;/code&gt; operation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwcnrjhb2fkjumd6z3mqn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwcnrjhb2fkjumd6z3mqn.jpg" alt="Deep merge fallback strategy ensuring valid UI even if KV store is disconnected" width="800" height="447"&gt;&lt;/a&gt;&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;// Logic inside fetchTranslations()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kvResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchFromKV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Might fail or be partial&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_DICTIONARIES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;// Always exists in memory&lt;/span&gt;

&lt;span class="c1"&gt;// If KV fails, we still render the page in English.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finalData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deepMerge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kvResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This guarantees &lt;strong&gt;100% Uptime&lt;/strong&gt; for your base language.&lt;/p&gt;

&lt;h3&gt;
  
  
  Solving Cache Invalidation (The "Hard" Problem)
&lt;/h3&gt;

&lt;p&gt;Earlier, when discussing The Cache API "Secret Sauce", we placed the Edge Cache in front of our KV store to avoid excessive reads. But how do you invalidate that cache when you fix a typo? Waiting for a TTL (Time To Live) to expire is annoying during deployments.&lt;/p&gt;

&lt;p&gt;We solved this with &lt;strong&gt;Content-Based Hashing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every time you run the build script (&lt;code&gt;npm run i18n:bundle&lt;/code&gt;), we calculate a SHA-hash of your translation files. This hash is injected into the code as a constant: &lt;code&gt;TRANSLATIONS_VERSION&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Cache Key structure looks like this:&lt;br&gt;
&lt;code&gt;project_id:i18n:v&amp;lt;HASH&amp;gt;::&amp;lt;locale&amp;gt;:&amp;lt;namespace&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scenario A (No changes):&lt;/strong&gt; You redeploy the code, but didn't touch locales. The Hash stays the same. The Cache HIT rate remains 100%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scenario B (Typo fix):&lt;/strong&gt; You change a string in &lt;code&gt;common.json&lt;/code&gt;. The Hash changes. The Worker immediately starts using a &lt;strong&gt;new&lt;/strong&gt; Cache Key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result? &lt;strong&gt;Instant updates&lt;/strong&gt; for users, with zero manual cache purging required.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Developer Experience (DX)
&lt;/h3&gt;

&lt;p&gt;Working with Edge KV stores can be tedious. I didn't want to manually use &lt;code&gt;wrangler kv:key put&lt;/code&gt; for every single JSON file.&lt;/p&gt;

&lt;p&gt;We automated the entire workflow with three scripts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;npm run i18n:bundle&lt;/code&gt;&lt;/strong&gt;: Scans &lt;code&gt;src/locales&lt;/code&gt;, generates the TypeScript Schema, calculates the Version Hash, and prepares a single JSON payload.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;npm run i18n:seed&lt;/code&gt;&lt;/strong&gt;: Uploads this payload to your &lt;strong&gt;Local&lt;/strong&gt; KV (Miniflare) so &lt;code&gt;npm run dev&lt;/code&gt; works offline.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;npm run i18n:migrate&lt;/code&gt;&lt;/strong&gt;: Uploads the payload to your &lt;strong&gt;Production&lt;/strong&gt; Cloudflare KV.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This makes the Edge feel just like Localhost. You change a JSON file, the types update instantly, and the data is one command away from global replication.&lt;/p&gt;
&lt;h2&gt;
  
  
  i18n URL Strategy: Why We Don't Translate Slugs
&lt;/h2&gt;

&lt;p&gt;When building a multilingual site, the instinct is often to translate &lt;em&gt;everything&lt;/em&gt;, including the URL path (&lt;code&gt;/de/blog/architektur&lt;/code&gt;). &lt;/p&gt;

&lt;p&gt;In EdgeKits, I deliberately chose &lt;strong&gt;not&lt;/strong&gt; to do this. We use &lt;strong&gt;English Slugs&lt;/strong&gt; across all locales (&lt;code&gt;/de/blog/architecture&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this wins:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Stable Sharing:&lt;/strong&gt; The URL is clean and short in any chat app, avoiding Percent-Encoding nightmares for non-Latin alphabets.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Simple Code:&lt;/strong&gt; We don't need reverse-lookup maps. The file system is the source of truth.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Automated SEO:&lt;/strong&gt; Generating &lt;code&gt;hreflang&lt;/code&gt; tags becomes a simple string replacement operation.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Graceful Degradation &amp;amp; The "Honest UX"
&lt;/h2&gt;

&lt;p&gt;By keeping the English slugs canonical, we solved the routing problem. But what happens at the file-system level?&lt;/p&gt;

&lt;p&gt;If a user visits &lt;code&gt;/es/blog/architecture&lt;/code&gt;, Astro will look for &lt;code&gt;src/content/blog/es/architecture.mdx&lt;/code&gt;. If you haven't written the Spanish translation yet, the standard behavior is to throw a 404 Error. Some developers solve this by copying the English &lt;code&gt;.mdx&lt;/code&gt; file into the &lt;code&gt;/es/&lt;/code&gt; folder just to prevent the crash. That is a maintenance nightmare.&lt;/p&gt;

&lt;p&gt;Because we decoupled the user's intent (&lt;code&gt;uiLocale&lt;/code&gt;) from the available data, we can handle this gracefully at the data-fetching layer. Inside our dynamic route (&lt;code&gt;[...slug].astro&lt;/code&gt;), we implemented a dual-fetch fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// pages/[lang]/blog/[...slug].astro

// ...

// 1. Try to fetch the requested translation
let post = await getEntry('blog', `${uiLocale}/${slug}`)

// 2. The Graceful Fallback: If missing, load the English original
if (!post) {
  post = await getEntry('blog', `${DEFAULT_LOCALE}/${slug}`)

  // Flag the missing content for the UI
  Astro.locals.isMissingContent = true
}

// 3. If it doesn't exist in English either, then it's a real 404
if (!post) {
  // Turns off the MissingTranslationBanner if it was triggered above
  Astro.locals.isMissingContent = false

  return Astro.rewrite(`/${uiLocale}/404/`)
}

// ...
---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The result is pure magic for the User Experience:&lt;/strong&gt;&lt;br&gt;
The article text renders in English, but the &lt;strong&gt;entire surrounding interface&lt;/strong&gt; — the navigation menu, the footer, and the formatted Publish Date — remains perfectly localized in Spanish. No 404s. No duplicated files.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Missing Translation Banner (Dual-Mode)
&lt;/h3&gt;

&lt;p&gt;However, silently swapping content languages can confuse users. To solve this, I introduced the "Honest UX" pattern via a &lt;code&gt;MissingTranslationBanner&lt;/code&gt; component.&lt;/p&gt;

&lt;p&gt;Instead of a generic warning, the system differentiates between two distinct failure modes: &lt;strong&gt;Missing Content&lt;/strong&gt; (Markdown) and &lt;strong&gt;Missing UI&lt;/strong&gt; (JSON dictionaries).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Content is missing:&lt;/strong&gt; If &lt;code&gt;Astro.locals.isMissingContent&lt;/code&gt; was flagged by our router, the banner tells the user specifically about the text: &lt;em&gt;"Sorry, this article is not yet available in your selected language."&lt;/em&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;UI is missing:&lt;/strong&gt; What if the Markdown content exists, but a translator forgot to add &lt;code&gt;blog.json&lt;/code&gt; to the Spanish directory? During the build phase (&lt;code&gt;npm run i18n:bundle&lt;/code&gt;), our script statically analyzes the filesystem and generates an array of &lt;code&gt;FULLY_TRANSLATED_LOCALES&lt;/code&gt;. If the current locale isn't in that list, the banner warns: &lt;em&gt;"Sorry, this page is not yet fully available in your selected language."&lt;/em&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because this banner is isolated, it reads the context directly from &lt;code&gt;Astro.locals&lt;/code&gt; and fetches its own localized strings from the &lt;code&gt;messages&lt;/code&gt; namespace. I also added a final layer of armor: explicit hardcoded fallbacks right inside the component, just in case the &lt;code&gt;messages.json&lt;/code&gt; dictionary itself is the one missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/domain/i18n/components/MissingTranslationBanner.astro

import { checkMissingTranslation } from '@/domain/i18n/resolve-locale'
import { fetchTranslations } from '@/domain/i18n/fetcher'

const missingType = checkMissingTranslation(
  Astro.locals.uiLocale,
  Astro.locals.isMissingContent,
)

let bannerText: string | null = null

if (missingType) {
  const { messages } = await fetchTranslations(
    Astro.locals.runtime,
    Astro.locals.translationLocale,
    ['messages'],
  )

  bannerText =
    missingType === 'content'
      ? messages.errors.ui.MISSING_TRANSLATED_CONTENT ||
        'Sorry, this article is not yet available in your selected language.'
      : messages.errors.ui.MISSING_TRANSLATED_UI ||
        'Sorry, this page is not yet fully available in your selected language.'
}
---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the function that triggers the banner:&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;// src/domain/i18n/resolve-locale.ts&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="c1"&gt;// Checking the completeness of translations&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isFullyTranslated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FULLY_TRANSLATED_LOCALES&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;MissingTranslationType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;checkMissingTranslation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;uiLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isMissingContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;MissingTranslationType&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ENABLE_MISSING_TRANSLATION_BANNER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isFullyTranslated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uiLocale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isMissingContent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;isMissingContent&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&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="s1"&gt;ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a robust, Zero-JS fallback mechanism that prioritizes transparency and stability above all else. &lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion: This Is Just the Beginning
&lt;/h2&gt;

&lt;p&gt;We started this journey with a heavy, client-side approach and ended up with an architecture that is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Fast:&lt;/strong&gt; Zero client-side JS for translations. 0ms CLS.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Safe:&lt;/strong&gt; Fully typed via generated TypeScript schemas.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Resilient:&lt;/strong&gt; Protected by Edge Caching and compiled Fallbacks.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Clean:&lt;/strong&gt; No "prop-drilling" hell, thanks to Middleware.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  Parts 2 and 3 is out! 🚀
&lt;/h3&gt;

&lt;p&gt;You can read the continuation where we tackle the Interactive Layer: Zod lazy validation, React Hook Form, and Cloudflare D1 JSON columns.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-2-359n"&gt;Read Part 2: Stop Shipping Translations to the Client here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How to completely separate translation from code deployment on Cloudflare Workers. Per-namespace cache keys and the Purge API for granular, instant i18n updates.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/garyedgekits/stop-redeploying-to-update-translations-granular-edge-cache-invalidation-with-cloudflare-purge-api-2cm7"&gt;Read Part 3: Stop Redeploying to Update Translations here&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get the Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You don't have to build this from scratch. The entire architecture discussed today is available as an open-source starter kit. &lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Star the Repo &amp;amp; Start Building:&lt;/strong&gt; &lt;a href="https://github.com/EdgeKits/astro-edgekits-core" rel="noopener noreferrer"&gt;https://github.com/EdgeKits/astro-edgekits-core&lt;/a&gt;&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>astro</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
