<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gary Stupak</title>
    <description>The latest articles on DEV Community by Gary Stupak (@garyedgekits).</description>
    <link>https://dev.to/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>DEV Community: Gary Stupak</title>
      <link>https://dev.to/garyedgekits</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/garyedgekits"/>
    <language>en</language>
    <item>
      <title>Astro i18n in 2026: The Complete Guide From ui.ts to Edge-Native KV</title>
      <dc:creator>Gary Stupak</dc:creator>
      <pubDate>Fri, 01 May 2026 10:07:19 +0000</pubDate>
      <link>https://dev.to/garyedgekits/astro-i18n-in-2026-the-complete-guide-from-uits-to-edge-native-kv-1gla</link>
      <guid>https://dev.to/garyedgekits/astro-i18n-in-2026-the-complete-guide-from-uits-to-edge-native-kv-1gla</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%2F2jzol317emcj43hx3677.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%2F2jzol317emcj43hx3677.jpg" alt="Conceptual illustration of an i18n maturity ladder for Astro - from a single ui.ts file at the bottom to an Edge-Native KV architecture at the top." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every practical approach to multilingual Astro sites: native routing, Content Collections, ui.ts, Paraglide JS, forms with Zod 4, and the Edge-Native KV pattern. Real-world trade-offs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I needed to ship EdgeKits.dev in four languages. The first 40% of the work was easy: configure &lt;code&gt;i18n&lt;/code&gt;, sprinkle &lt;code&gt;getRelativeLocaleUrl&lt;/code&gt; where needed, organize the folders by locale. The other 60% was a tour through the i18n ecosystem most tutorials politely skip.&lt;/p&gt;

&lt;p&gt;I started with bundled &lt;code&gt;ui.ts&lt;/code&gt; dictionaries - clean enough until they weren't. Then Paraglide, whose client-side tree-shaking is brilliant - until you run the server-side math forward and watch every additional locale and every new namespace pile more translation code into the bundle that was supposed to hold your business logic. &lt;/p&gt;

&lt;p&gt;The deploy side wasn't quiet either: every hero-copy iteration was costing a full rebuild, and the lean deploy flow I'd promised myself was no longer lean.&lt;/p&gt;

&lt;p&gt;Every layer of this problem has a fix - and every fix has a cost the docs forget to print.&lt;/p&gt;

&lt;p&gt;This guide is the map I wish I had then.&lt;/p&gt;

&lt;p&gt;We'll walk seven levels of i18n maturity in Astro, from "I just need a &lt;code&gt;/en/&lt;/code&gt; and &lt;code&gt;/es/&lt;/code&gt; folder" to "translations are runtime data, not build-time code." At each level we'll look at what you actually get, what it costs, and the specific symptoms that mean you've outgrown it.&lt;/p&gt;

&lt;p&gt;We'll cover Astro 5–6's native i18n routing, the official &lt;code&gt;ui.ts&lt;/code&gt; recipe, and Paraglide JS v2. We'll look at the state of &lt;code&gt;astro-i18next&lt;/code&gt; and &lt;code&gt;astro-i18n-aut&lt;/code&gt; in 2026 - one is archived, the other isn't, but probably should be. &lt;/p&gt;

&lt;p&gt;And we'll work through the form-validation problem with Zod 4 and React Hook Form, the deployment treadmill that hits as soon as a CMS shows up, and the bundle limits that hit if a CMS doesn't.&lt;/p&gt;

&lt;p&gt;By the end you should know exactly which level fits your project today - and which symptom will tell you when it's time to move up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Astro i18n Is Two Problems, Not One
&lt;/h2&gt;

&lt;p&gt;Astro i18n is talked about as one topic, but it's really two - and most resources you'll find treat them as one. &lt;/p&gt;

&lt;p&gt;Organizing blog posts in &lt;code&gt;src/content/blog/en/&lt;/code&gt; and &lt;code&gt;src/content/blog/es/&lt;/code&gt; is one problem. &lt;/p&gt;

&lt;p&gt;Defining a dictionary that maps &lt;code&gt;nav.home&lt;/code&gt; to &lt;code&gt;"Home"&lt;/code&gt; is a different problem. &lt;/p&gt;

&lt;p&gt;Both get framed as "translation." Both are real. They share a feature in &lt;code&gt;astro.config.mjs&lt;/code&gt; and almost nothing else.&lt;/p&gt;

&lt;p&gt;Mixing them up is how you end up choosing a tool for the wrong layer and not understanding why it doesn't quite fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Content Localization vs UI Localization
&lt;/h3&gt;

&lt;p&gt;The first layer is &lt;strong&gt;content&lt;/strong&gt;: full pages and page-sized chunks. Blog posts, documentation, marketing landing copy, MDX articles. The translation unit is a whole document, typically in Markdown or MDX with frontmatter. Authors are content people. Edits happen at content cadence - once a week, once a month, once when somebody flags a typo.&lt;/p&gt;

&lt;p&gt;The second layer is &lt;strong&gt;UI&lt;/strong&gt;: small strings interleaved with code. Button labels, form placeholders, validation errors, toast messages, navigation items, footer microcopy. The translation unit is a key-value pair. Authors are developers. Edits ship every time you ship a feature.&lt;/p&gt;

&lt;p&gt;Different translation units, different authors, different cadence, different storage, different runtime. Until you see the split clearly, every i18n tool feels half-right.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Map of Tools to Layers
&lt;/h3&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%2Fk4me5dw4dfxkw2kan2ge.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%2Fk4me5dw4dfxkw2kan2ge.jpg" alt="Two-column diagram mapping Astro i18n tools to layers — Content Collections, [locale] dynamic routing, and headless CMS on the content side; ui.ts, Paraglide, astro-i18next, and runtime KV on the UI side; native Astro i18n routing as the foundation underneath both." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Astro Content Collections, the &lt;code&gt;[locale]&lt;/code&gt; dynamic-route pattern, and headless CMS integrations all live on the content side. They're optimized for documents.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ui.ts&lt;/code&gt; dictionaries, Paraglide JS, &lt;code&gt;astro-i18next&lt;/code&gt;, &lt;code&gt;astro-i18n-aut&lt;/code&gt;, and the runtime KV pattern we'll get to in Level 7 all live on the UI side. They're optimized for strings.&lt;/p&gt;

&lt;p&gt;Native Astro i18n routing - the &lt;code&gt;i18n&lt;/code&gt; config in &lt;code&gt;astro.config.mjs&lt;/code&gt;, &lt;code&gt;getRelativeLocaleUrl&lt;/code&gt;, &lt;code&gt;Astro.currentLocale&lt;/code&gt; - sits underneath both layers. It tells the framework which language a request is for. It does not localize anything.&lt;/p&gt;

&lt;p&gt;Hold this two-layer model in your head for the rest of the guide. Every tool we discuss solves exactly one of these two problems - most of the confusion in the ecosystem comes from libraries that talk like they solve both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 1 - Astro Native i18n Routing (What You Actually Get for Free)
&lt;/h2&gt;

&lt;p&gt;Astro has had built-in i18n routing since version 4, with default-behavior tweaks in 5 and 6. The first thing to understand about it: it does not localize anything. It tells the framework which language a request is for. That's a foundational primitive - and it's also the entire scope of the feature.&lt;/p&gt;

&lt;p&gt;What you get is URL routing with locale awareness, helpers to generate locale-aware links, and a fallback policy when content is missing in a given language. What you don't get is a translation system, a dictionary format, or any opinion on how &lt;code&gt;Hello world&lt;/code&gt; becomes &lt;code&gt;Hola mundo&lt;/code&gt; on the page.&lt;/p&gt;

&lt;p&gt;Native routing is the foundation underneath every other level in this guide. Get it set up correctly and most of what follows builds cleanly on top. Skip it and you'll spend the next three levels reinventing things Astro already does.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;i18n&lt;/code&gt; Config in &lt;code&gt;astro.config.mjs&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The starting point is a single block in your Astro config:&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;// astro.config.mjs&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;defineConfig&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/config&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;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;defaultLocale&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;locales&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;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;es&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;de&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;ja&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;prefixDefaultLocale&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="na"&gt;redirectToDefaultLocale&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="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&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;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;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;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;ja&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="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 five options that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;defaultLocale&lt;/code&gt;&lt;/strong&gt; - the locale used when no other signal is available. Must be one of the entries in &lt;code&gt;locales&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;locales&lt;/code&gt;&lt;/strong&gt; - every locale the site supports. Plain strings work for simple cases; you can also pass objects with &lt;code&gt;path&lt;/code&gt; and &lt;code&gt;codes&lt;/code&gt; if you want to group several BCP 47 codes (e.g., &lt;code&gt;en-US&lt;/code&gt; and &lt;code&gt;en-GB&lt;/code&gt;) under a single URL path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;routing.prefixDefaultLocale&lt;/code&gt;&lt;/strong&gt; - whether the default locale appears in URLs as a prefix. Discussed in detail below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;routing.redirectToDefaultLocale&lt;/code&gt;&lt;/strong&gt; - when the default locale has no prefix, whether requests like &lt;code&gt;/en/about&lt;/code&gt; should redirect to &lt;code&gt;/about&lt;/code&gt;. Also discussed below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fallback&lt;/code&gt;&lt;/strong&gt; - when a locale is missing a route, which other locale's version Astro should render in its place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also &lt;code&gt;i18n.domains&lt;/code&gt;, which maps locales to fully-qualified domains. That's its own pattern with its own trade-offs and gets its own section below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Routing Strategies: Prefix Default vs Prefix Others Only
&lt;/h3&gt;

&lt;p&gt;The two routing options interact with each other. Get them right and your URL structure is exactly what you want. Get them wrong and you can ship infinite-loop redirects to production.&lt;/p&gt;

&lt;p&gt;Two combinations make sense in practice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default locale at root, others prefixed.&lt;/strong&gt; &lt;code&gt;prefixDefaultLocale: false&lt;/code&gt; with &lt;code&gt;redirectToDefaultLocale: true&lt;/code&gt;. English lives at &lt;code&gt;/about&lt;/code&gt;, Spanish at &lt;code&gt;/es/about&lt;/code&gt;, German at &lt;code&gt;/de/about&lt;/code&gt;. Cleanest for sites with a primary-language audience. The redirect setting means anyone hitting &lt;code&gt;/en/about&lt;/code&gt; is bounced to &lt;code&gt;/about&lt;/code&gt; - which keeps Google from indexing both URLs as duplicate content for the same page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All locales prefixed.&lt;/strong&gt; &lt;code&gt;prefixDefaultLocale: true&lt;/code&gt;. English at &lt;code&gt;/en/about&lt;/code&gt;, Spanish at &lt;code&gt;/es/about&lt;/code&gt;. Cleaner for analytics (every URL is locale-tagged), better for sites where no language is "primary." &lt;code&gt;redirectToDefaultLocale&lt;/code&gt; is a no-op in this mode.&lt;/p&gt;

&lt;p&gt;A note on Astro 6: the default for &lt;code&gt;redirectToDefaultLocale&lt;/code&gt; flipped from &lt;code&gt;true&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; to remove a footgun. With the old default, a &lt;code&gt;prefixDefaultLocale: false&lt;/code&gt; setup combined with certain custom redirect chains could produce infinite-loop redirects in production. &lt;/p&gt;

&lt;p&gt;If you're upgrading from Astro 5 and your routing suddenly behaves differently, this is the first thing to check. Setting both flags explicitly - as in the snippet above - makes the config behave the same across both versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Subdirectories vs Subdomains: A Short Decision Note
&lt;/h3&gt;

&lt;p&gt;There's an old SEO debate about whether multilingual sites should use subdirectories (&lt;code&gt;/es/about&lt;/code&gt;) or subdomains (&lt;code&gt;es.example.com&lt;/code&gt;). For most projects, subdirectories win. They share link equity, they simplify deployment, and Astro's default routing handles them out of the box.&lt;/p&gt;

&lt;p&gt;Subdomains make sense when each language is functionally a separate site: different content team, different deployment cadence, different stack underneath. For that case, Astro has &lt;code&gt;i18n.domains&lt;/code&gt; (covered below), which lets you map locales to fully-qualified domains while keeping a single codebase.&lt;/p&gt;

&lt;p&gt;The third option - country-code TLDs (&lt;code&gt;example.de&lt;/code&gt;, &lt;code&gt;example.es&lt;/code&gt;) - is a much bigger commitment. It signals to Google that you're targeting a specific country, not just a language. Use it when you're a multinational brand with country-specific operations, not when you're a SaaS that happens to support German.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;astro:i18n&lt;/code&gt; Helpers You'll Actually Use
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;astro:i18n&lt;/code&gt; module exports a small set of utilities for working with locale-aware URLs. Most of them you'll touch exactly once - in your language switcher and your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; - and never think about again.&lt;/p&gt;

&lt;p&gt;The ones worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getRelativeLocaleUrl(locale, path)&lt;/code&gt;&lt;/strong&gt; - builds an internal link for a specific locale: &lt;code&gt;/es/about&lt;/code&gt;. Use it for nav menus, in-content links, and the language switcher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getAbsoluteLocaleUrl(locale, path)&lt;/code&gt;&lt;/strong&gt; - same thing but fully qualified: &lt;code&gt;https://example.com/es/about&lt;/code&gt;. Use it for canonical URLs, hreflang tags, and anywhere a string URL leaves your site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getRelativeLocaleUrlList(path)&lt;/code&gt;&lt;/strong&gt; - returns the same path mapped across every configured locale. Built for language switchers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getAbsoluteLocaleUrlList(path)&lt;/code&gt;&lt;/strong&gt; - same, fully qualified. Built for hreflang generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getPathByLocale(locale)&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;getLocaleByPath(path)&lt;/code&gt;&lt;/strong&gt; - convert between locales and their URL paths. Useful when you've configured &lt;code&gt;locales&lt;/code&gt; with custom &lt;code&gt;path&lt;/code&gt; values.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the request side, Astro exposes two locale values automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Astro.currentLocale&lt;/code&gt;&lt;/strong&gt; - the locale resolved from the current URL, based on your &lt;code&gt;i18n&lt;/code&gt; config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Astro.preferredLocale&lt;/code&gt;&lt;/strong&gt; - the visitor's preferred locale, derived from the &lt;code&gt;Accept-Language&lt;/code&gt; header and matched against your configured &lt;code&gt;locales&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal language switcher built on these primitives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/components/LanguageSwitcher.astro

import { getRelativeLocaleUrlList } from 'astro:i18n'

const currentLocale = Astro.currentLocale ?? 'en'
const localeUrls = getRelativeLocaleUrlList(Astro.url.pathname)

const labels: Record&amp;lt;string, string&amp;gt; = {
  en: 'English',
  es: 'Español',
  de: 'Deutsch',
  ja: '日本語',
}
---

&amp;lt;nav aria-label="Language"&amp;gt;
  &amp;lt;ul&amp;gt;
    {
      localeUrls.map((url) =&amp;gt; {
        const locale = url.split('/').filter(Boolean)[0] ?? 'en'
        return (
          &amp;lt;li&amp;gt;
            &amp;lt;a
              href={url}
              aria-current={locale === currentLocale ? 'page' : undefined}
            &amp;gt;
              {labels[locale] ?? locale}
            &amp;lt;/a&amp;gt;
          &amp;lt;/li&amp;gt;
        )
      })
    }
  &amp;lt;/ul&amp;gt;
&amp;lt;/nav&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what isn't in this list: there's no &lt;code&gt;t()&lt;/code&gt;, no formatter, no plural rules, no message catalog. The &lt;code&gt;astro:i18n&lt;/code&gt; module is a routing utility, not an i18n library. Once you accept that, the rest of the maturity ladder makes sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  Browser Language Detection (and Why Not to Over-Redirect)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Astro.preferredLocale&lt;/code&gt; is the easiest way to detect what language the visitor's browser is asking for. It's a server-side value, derived from &lt;code&gt;Accept-Language&lt;/code&gt;, matched against your &lt;code&gt;locales&lt;/code&gt;. Free signal, no client-side JavaScript.&lt;/p&gt;

&lt;p&gt;The temptation is to wire it directly to a redirect: visitor lands on &lt;code&gt;/about&lt;/code&gt;, browser sends &lt;code&gt;de&lt;/code&gt;, ship them to &lt;code&gt;/de/about&lt;/code&gt;. Don't do this - at least not by default.&lt;/p&gt;

&lt;p&gt;The visitor has not asked for German. They've followed a link. Maybe a colleague sent them a URL. Maybe they're verifying a translation. Maybe they're an English-speaking developer living in Berlin who has their browser set to German for system reasons. &lt;/p&gt;

&lt;p&gt;Forcibly redirecting based on browser preference creates a class of UX bugs that are hard to reproduce and harder to debug - the URL the user typed is not the URL they end up on, and the reason lives inside their browser's locale settings.&lt;/p&gt;

&lt;p&gt;The pattern that survives contact with real users: respect URL &amp;gt; cookie &amp;gt; &lt;code&gt;Accept-Language&lt;/code&gt;, in that order. If the URL specifies a locale, that wins. If the URL is locale-neutral but the user has a saved cookie from a previous visit, that wins. Only when both are absent does the browser preference get a vote. &lt;/p&gt;

&lt;p&gt;And even then, consider showing a banner - "We have this page in German - switch?" - instead of silently redirecting. Users are better at managing their own language preferences than your &lt;code&gt;Accept-Language&lt;/code&gt; parser is at guessing them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Domain SEO with &lt;code&gt;i18n.domains&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;For most projects, the subdirectory model - &lt;code&gt;example.com/es/about&lt;/code&gt; - is the right answer. But there's a specific case where you actually want each locale to live on its own fully-qualified domain: when each locale is functionally a separate site. Different country team, different content priorities, different deployment cadence.&lt;/p&gt;

&lt;p&gt;For that case, Astro exposes &lt;code&gt;i18n.domains&lt;/code&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;// astro.config.mjs&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;defaultLocale&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;locales&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;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;es&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;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;prefixDefaultLocale&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="na"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&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;https://example.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;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;https://example.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;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;After this, &lt;code&gt;getAbsoluteLocaleUrl('es', '/about')&lt;/code&gt; returns &lt;code&gt;https://example.es/about&lt;/code&gt; instead of &lt;code&gt;https://example.com/es/about&lt;/code&gt;. Hreflang generators, sitemaps, and canonical link tags all pick up the correct domain automatically.&lt;/p&gt;

&lt;p&gt;Two things to know before you reach for this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It requires a server-rendered adapter.&lt;/strong&gt; &lt;code&gt;@astrojs/node&lt;/code&gt; and &lt;code&gt;@astrojs/vercel&lt;/code&gt; support &lt;code&gt;domains&lt;/code&gt;. Static-only output doesn't, because the cross-domain URL rewriting happens at request time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You lose subdirectory link equity.&lt;/strong&gt; Authority and backlinks no longer pool across locales - &lt;code&gt;example.es&lt;/code&gt; is its own SEO entity. For most multilingual sites, that's a net loss. For brands where each country site really is a different operation, it's the right structural signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Country-code TLDs are a step beyond &lt;code&gt;i18n.domains&lt;/code&gt; (covered in the previous subsection on subdirectories vs subdomains) - different country team, different SEO strategy, different infrastructure. They're out of scope here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Localized Slugs vs Canonical English Slugs
&lt;/h3&gt;

&lt;p&gt;There's a recurring debate when designing multilingual URL structures: should the slug - the human-readable part of the URL after the locale prefix - be translated?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/en/blog/apple-crumble&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/es/blog/apple-crumble&lt;/code&gt; (canonical English slug)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/es/blog/manzana-crumble&lt;/code&gt; (localized slug)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The case for localized slugs is mostly an SEO argument: a Spanish reader searching for "manzana crumble receta" will see &lt;code&gt;/es/blog/manzana-crumble&lt;/code&gt; as a more relevant URL than &lt;code&gt;/es/blog/apple-crumble&lt;/code&gt;. For long-form content where the slug literally is a search query, that effect is real.&lt;/p&gt;

&lt;p&gt;The case against is more practical, and it adds up fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Percent-encoding turns clean URLs into garbage.&lt;/strong&gt; A Cyrillic slug like &lt;code&gt;българска-кухня&lt;/code&gt; becomes &lt;span&gt;&lt;code&gt;%D0%B1%D1%8A%D0%BB%D0%B3%D0%B0%D1%80%D1%81%D0%BA%D0%B0-%D0%BA%D1%83%D1%85%D0%BD%D1%8F&lt;/code&gt;&lt;/span&gt; the moment someone copies the link into a chat client. Looks suspicious to anyone reading the URL, breaks the clean aesthetic, and obscures whatever meaningful identifier was supposed to be in the path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform filesystem fragility.&lt;/strong&gt; Different operating systems normalize Unicode differently (macOS NFD vs Linux/Windows NFC). Filenames like &lt;code&gt;bulgarska-kuhnya.md&lt;/code&gt; are fine. Filenames like &lt;code&gt;българска-кухня.md&lt;/code&gt; produce "file not found" errors in CI pipelines on platforms that disagree with your dev machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architectural overhead.&lt;/strong&gt; Translated slugs need a lookup table - &lt;code&gt;българска-кухня → bulgarian-cuisine&lt;/code&gt; - to resolve language-switcher links. That's a route map that has to be loaded on every request, and it grows linearly with your content volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For EdgeKits.dev I went with canonical English slugs for everything: &lt;code&gt;/en/blog/edge-native-i18n-astro-cloudflare-part-1&lt;/code&gt;, &lt;code&gt;/de/blog/edge-native-i18n-astro-cloudflare-part-1&lt;/code&gt;, &lt;code&gt;/ja/blog/edge-native-i18n-astro-cloudflare-part-1&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;The locale prefix changes; the slug doesn't. Hreflang generation reduces to a string-replacement operation, language switchers don't need a lookup, and URLs stay copy-pasteable in any chat client.&lt;/p&gt;

&lt;p&gt;The counterargument: for a Spanish-language recipe blog targeting a Spanish-speaking audience, localized slugs probably are worth the operational cost. For an English-first technical blog with translated coverage, they aren't. &lt;/p&gt;

&lt;p&gt;Pick based on whether your slugs are content (where translation helps SEO) or identifiers (where stability helps everything else).&lt;/p&gt;

&lt;h3&gt;
  
  
  RTL Languages: A Short Note on &lt;code&gt;dir&lt;/code&gt; and Logical Properties
&lt;/h3&gt;

&lt;p&gt;Astro's i18n config is locale-aware but writing-direction-agnostic. If you support Arabic, Hebrew, Farsi, or Urdu, you'll need to handle the right-to-left direction yourself - and the work is mostly two lines of code plus a CSS habit.&lt;/p&gt;

&lt;p&gt;The HTML side: set &lt;code&gt;dir&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; based on the current locale.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/layouts/BaseLayout.astro

const currentLocale = Astro.currentLocale ?? 'en'

const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
const dir = RTL_LOCALES.has(currentLocale) ? 'rtl' : 'ltr'
---

&amp;lt;html lang={currentLocale} dir={dir}&amp;gt;
  &amp;lt;!-- ... --&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CSS side: use logical properties instead of physical ones. &lt;code&gt;padding-inline-start&lt;/code&gt; instead of &lt;code&gt;padding-left&lt;/code&gt;. &lt;code&gt;margin-inline-end&lt;/code&gt; instead of &lt;code&gt;margin-right&lt;/code&gt;. &lt;code&gt;text-align: start&lt;/code&gt; instead of &lt;code&gt;text-align: left&lt;/code&gt;. The browser flips them automatically based on &lt;code&gt;dir&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;padding-inline-start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* "left" in LTR, "right" in RTL */&lt;/span&gt;
  &lt;span class="py"&gt;border-inline-start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent&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;Adopt logical properties from day one and RTL support is mostly automatic. Retrofitting them onto a years-old physical-property codebase is the painful version of this work - the only reliable way through it is grinding the codebase one component at a time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Native Routing Stops Helping You
&lt;/h3&gt;

&lt;p&gt;We've now covered everything Astro's native i18n routing actually does: it routes URLs by locale, exposes helpers for generating those URLs, gracefully falls back when a translation is missing, optionally maps locales to separate domains, and tells you what language the current request is for.&lt;/p&gt;

&lt;p&gt;What it doesn't do - and what every remaining level of this guide exists to handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It doesn't translate any text. &lt;code&gt;Astro.currentLocale&lt;/code&gt; returns &lt;code&gt;'es'&lt;/code&gt;; it doesn't tell you that the Spanish word for "Subscribe" is "Suscribirse."&lt;/li&gt;
&lt;li&gt;It doesn't manage your translated content. You still need a folder structure (Level 2), a dictionary (Level 3), or a library (Level 4) to actually map keys or files to localized output.&lt;/li&gt;
&lt;li&gt;It has no opinion on interactivity. React Hook Form validation errors, toast messages, anything that happens after hydration - the routing layer is silent on all of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Native routing is necessary and rarely sufficient. Before we climb to Level 2, there's one transversal concern that affects everything we've built so far and everything we're about to build: i18n SEO - the obligations that come with making a multilingual Astro site indexable correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multilingual SEO Essentials: hreflang, Sitemap, RSS, and llms.txt
&lt;/h2&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%2F8r2agp4fnad5v8ny7354.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%2F8r2agp4fnad5v8ny7354.jpg" alt="Multilingual i18n SEO Essentials infographic covering hreflang tags for language targeting, XML sitemap structure, multilingual RSS feeds, and llms.txt for AI comprehension. Complete guide to i18n SEO for global Astro websites and search discovery." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why i18n SEO Is Its Own Discipline
&lt;/h3&gt;

&lt;p&gt;Astro's i18n config gets you locale-aware URLs. Native helpers get you locale-aware links. None of that, by itself, tells search engines that &lt;code&gt;/de/about&lt;/code&gt; is the German version of &lt;code&gt;/about&lt;/code&gt;, that &lt;code&gt;/ja/blog/post-1&lt;/code&gt; should only be served when there actually is a Japanese version of post-1, or that AI agents indexing your site should treat your &lt;code&gt;llms.txt&lt;/code&gt; one specific way regardless of how many locales you support.&lt;/p&gt;

&lt;p&gt;What's commonly called i18n SEO sits on top of all of this. It's the part of multilingual implementation where small mistakes compound silently for months before surfacing in a Search Console report. The next subsections cover the obligations that actually matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;hreflang&lt;/code&gt; Tags: The Non-Negotiable
&lt;/h3&gt;

&lt;p&gt;The single most important multilingual SEO signal. &lt;code&gt;hreflang&lt;/code&gt; tells Google which URLs are translations of each other and which language each one is in. Without it, you're shipping near-duplicate content to the index and asking Google to figure out the relationships on its own - which it does, badly.&lt;/p&gt;

&lt;p&gt;The minimum viable implementation, in a layout file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/layouts/BaseLayout.astro

import { getAbsoluteLocaleUrl } from 'astro:i18n'

const SUPPORTED_LOCALES = ['en', 'es', 'de', 'ja'] as const
const DEFAULT_LOCALE = 'en'

// Strip the locale prefix to get the underlying path
const localePattern = new RegExp(`^/(${SUPPORTED_LOCALES.join('|')})`)
const cleanPath = Astro.url.pathname.replace(localePattern, '') || '/'
---

&amp;lt;head&amp;gt;
  {
    SUPPORTED_LOCALES.map((locale) =&amp;gt; (
      &amp;lt;link
        rel="alternate"
        hreflang={locale}
        href={getAbsoluteLocaleUrl(locale, cleanPath)}
      /&amp;gt;
    ))
  }
  &amp;lt;link
    rel="alternate"
    hreflang="x-default"
    href={getAbsoluteLocaleUrl(DEFAULT_LOCALE, cleanPath)}
  /&amp;gt;
&amp;lt;/head&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things this snippet gets right out of the box: it includes an &lt;code&gt;x-default&lt;/code&gt; tag (the locale Google should serve when the user's language is something you don't support), and it generates one &lt;code&gt;alternate&lt;/code&gt; per configured locale. The pattern works for sites where every page exists in every locale.&lt;/p&gt;

&lt;p&gt;It breaks as soon as your site has partial translations.&lt;/p&gt;

&lt;h4&gt;
  
  
  Don't Lie About Translations That Don't Exist
&lt;/h4&gt;

&lt;p&gt;If your site translates UI strings into German but your blog posts are English-only, the naive hreflang implementation tells Google that &lt;code&gt;/de/blog/post-1&lt;/code&gt; is the German version of &lt;code&gt;post-1&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;Google crawls that URL, gets back a page that's mostly in English (with German nav and footer), and starts asking awkward questions in your indexing reports.&lt;/p&gt;

&lt;p&gt;The pattern that actually works: emit &lt;code&gt;hreflang&lt;/code&gt; tags only for locales that have translated content for the current page, and mark fallback pages as &lt;code&gt;noindex&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/pages/[lang]/blog/[...slug].astro

import { getCollection } from 'astro:content'
import BaseLayout from '@/layouts/BaseLayout.astro'

const { lang, slug } = Astro.params

// Find which locales actually have a translation for this slug
const allPosts = await getCollection('blog')
const translations = allPosts.filter((p) =&amp;gt; {
  const [, ...rest] = p.id.split('/')
  return rest.join('/') === slug
})
const translatedLocales = translations.map((p) =&amp;gt; p.id.split('/')[0])

// Are we rendering a fallback (this locale has no translation)?
const isFallback = !translatedLocales.includes(lang!)

// `post` is fetched separately with the fallback chain shown in Level 2.
---

&amp;lt;BaseLayout
  title={post.data.title}
  altLocales={translatedLocales}
  noindex={isFallback}
&amp;gt;
  &amp;lt;!-- ... --&amp;gt;
&amp;lt;/BaseLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BaseLayout&lt;/code&gt; then uses &lt;code&gt;altLocales&lt;/code&gt; to emit &lt;code&gt;hreflang&lt;/code&gt; only for the translations that exist, and &lt;code&gt;noindex&lt;/code&gt; to keep fallback pages out of the index. Google sees a coherent map of what's actually translated, and stops indexing pages that lie about their language.&lt;/p&gt;

&lt;h3&gt;
  
  
  Canonical URLs Per Locale
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;hreflang&lt;/code&gt; tags tell search engines about translations. &lt;code&gt;canonical&lt;/code&gt; tells them which URL is the source of truth for the current page. Every locale needs its own canonical pointing at itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;link rel="canonical" href={new URL(Astro.url.pathname, Astro.site)} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire pattern. The Spanish version of a page should canonical to itself, not to the English version. Pointing every locale's canonical to English is one of the more common multilingual-Astro mistakes I've seen in the wild - it quietly deindexes every translation you've shipped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use 301 (Not 302) for Every Locale Redirect
&lt;/h3&gt;

&lt;p&gt;Native i18n routing - and any custom middleware doing locale fallback - generates redirects on a few predictable paths: a request without a locale prefix gets bounced to &lt;code&gt;/en/&lt;/code&gt;, an unsupported locale gets normalized to the default, an old URL shape gets normalized to the canonical one. Every one of these has to be a &lt;strong&gt;301 (permanent)&lt;/strong&gt;, never a 302 (temporary).&lt;/p&gt;

&lt;p&gt;Two failure modes follow from getting this wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Search Console treats 302 as "the original URL is canonical."&lt;/strong&gt; A 302 from &lt;code&gt;/about&lt;/code&gt; to &lt;code&gt;/en/about&lt;/code&gt; tells Google &lt;code&gt;/about&lt;/code&gt; is the real page and &lt;code&gt;/en/about&lt;/code&gt; is a temporary alias. Your &lt;code&gt;canonical&lt;/code&gt; tags point one way, your redirects point the other, and the index fragments. 301 is the only status code that consolidates the signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch-all handlers responding 200 instead of 301-or-404 produce soft 404s and indexable URL spam.&lt;/strong&gt; If &lt;code&gt;/asdfsdf&lt;/code&gt; (a path nothing maps to) returns &lt;code&gt;200 OK&lt;/code&gt; with a rendered fallback page rather than &lt;code&gt;301&lt;/code&gt; to a real page or &lt;code&gt;404&lt;/code&gt; to nowhere, Google indexes a flood of nonsense URLs. This is the most common form of indexable URL noise on multilingual sites - usually caused by overly permissive locale-prefix handling that tries to "be helpful" instead of returning a hard signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're writing locale-prefix middleware, make every &lt;code&gt;redirect()&lt;/code&gt; call pass &lt;code&gt;301&lt;/code&gt; explicitly. Don't rely on framework defaults. The middleware in EdgeKits Core uses 301 on every fallback path; the pattern looks like:&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;// Always 301 - never let the default leak through&lt;/span&gt;
&lt;span class="k"&gt;return&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="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="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For paths that don't exist at all, return a real &lt;code&gt;404&lt;/code&gt; rather than redirecting to a fallback locale's homepage. Soft 404s look like 200s to Google, and Google believes them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Trailing-Slash Trap
&lt;/h3&gt;

&lt;p&gt;Here's a footgun I personally walked into. Three months in, I'm still negotiating with Google in Search Console about which URL is canonical on a handful of my secondary pages. The trap applies to any Astro site; multilingual ones take the worst of it. Worth covering before we touch sitemaps and RSS.&lt;/p&gt;

&lt;p&gt;I had &lt;code&gt;trailingSlash: 'ignore'&lt;/code&gt; in my Astro config - deliberately, because my middleware already canonicalizes URLs in a single redirect (locale prefix, trailing slash, normalized structure all in one hop), and &lt;code&gt;trailingSlash: 'always'&lt;/code&gt; would have added a redundant second redirect on top. &lt;/p&gt;

&lt;p&gt;That part of the setup was correct. The mistake lived elsewhere: I let individual components compose URLs by hand instead of routing every internal link through a single helper. Different components ended up emitting different conventions - some with trailing slashes (&lt;code&gt;/ja/legal/&lt;/code&gt;), some without (&lt;code&gt;/ja/legal&lt;/code&gt;). Both versions resolved correctly at runtime, so nothing visibly broke during development.&lt;/p&gt;

&lt;p&gt;What did break was Google. Both versions got indexed. Then Google started disagreeing with me about which one was canonical, on a per-page basis, in ways I couldn't predict. Some pages settled on the slash version, some on the no-slash version, some kept flipping. Three months of "URL is not canonical" warnings in Search Console for what was supposed to be a clean multilingual site.&lt;/p&gt;

&lt;p&gt;The lesson, applicable to any Astro site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth for URL canonicalization - and don't let it fight your middleware.&lt;/strong&gt; Astro's &lt;code&gt;trailingSlash: 'always'&lt;/code&gt; (or &lt;code&gt;'never'&lt;/code&gt;) handles canonicalization for you, at the cost of an extra redirect hop. If your middleware is already doing locale-aware redirects (cookie sync, locale resolution, soft-404 handling), &lt;code&gt;'ignore'&lt;/code&gt; plus a normalization step in middleware is often the cleaner pick - one redirect, one canonical form. What does &lt;em&gt;not&lt;/em&gt; work is any &lt;code&gt;trailingSlash&lt;/code&gt; setting combined with components that compose URLs by hand without going through a shared helper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build every URL through a single helper.&lt;/strong&gt; Whether that's &lt;code&gt;astro:i18n&lt;/code&gt;'s &lt;code&gt;getRelativeLocaleUrl&lt;/code&gt;, a thin wrapper around it, or your own utility, a single chokepoint guarantees one canonical form for every link. This is the discipline that saves you, regardless of which &lt;code&gt;trailingSlash&lt;/code&gt; mode you picked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force the slash in your canonical helper.&lt;/strong&gt; Even if a path comes in without one, append it before emitting the canonical link. Otherwise you re-export the inconsistency at the SEO layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal canonical helper that survives this class of bug:&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/lib/seo.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;buildCanonicalUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&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;siteOrigin&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="c1"&gt;// Strip query parameters&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cleanPath&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;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="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="c1"&gt;// Force trailing slash&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;cleanPath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&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="nx"&gt;cleanPath&lt;/span&gt; &lt;span class="o"&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="c1"&gt;// Avoid double slashes if origin has a trailing one&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;siteOrigin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&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;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)}${&lt;/span&gt;&lt;span class="nx"&gt;cleanPath&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines of discipline that would have saved me a quarter.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Multilingual Sitemap
&lt;/h3&gt;

&lt;p&gt;A sitemap tells search engines which URLs exist and how to reach them. For a multilingual site, that means listing every URL in every supported locale - not just the default one - and ideally cross-linking translations via &lt;code&gt;&amp;lt;xhtml:link rel="alternate"&amp;gt;&lt;/code&gt; entries.&lt;/p&gt;

&lt;p&gt;The minimum-effort option is &lt;code&gt;@astrojs/sitemap&lt;/code&gt; with the &lt;code&gt;i18n&lt;/code&gt; config:&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;// astro.config.mjs&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;defineConfig&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/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;sitemap&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;@astrojs/sitemap&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;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;sitemap&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;defaultLocale&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;locales&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;en&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-US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&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-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;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-DE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;ja&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-JP&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;This generates a sitemap with locale-tagged entries and alternate links between translations. It works well when every page exists in every locale.&lt;/p&gt;

&lt;p&gt;For sites where translations are partial - some posts in English only, some translated, some content-fallback - the integration starts producing noisy or incorrect output: alternate links to non-existent pages, sitemap entries for URLs that fall back to a different locale's content. &lt;/p&gt;

&lt;p&gt;At that point the cleanest fix is a custom sitemap endpoint that walks your collections and emits only the URLs that actually exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multilingual RSS: A Briefly Overlooked Detail
&lt;/h3&gt;

&lt;p&gt;RSS gets less attention in 2026 than it deserves - it's how AI summarizers, podcast players, content aggregators, and a meaningful slice of your power readers actually consume your blog.&lt;/p&gt;

&lt;p&gt;The choice for multilingual sites is one feed per locale (&lt;code&gt;/en/rss.xml&lt;/code&gt;, &lt;code&gt;/de/rss.xml&lt;/code&gt;) versus a single feed with &lt;code&gt;xml:lang&lt;/code&gt; tagged on each item. One-feed-per-locale is the more compatible option: every RSS reader handles it cleanly, and subscribers self-select their language by which URL they subscribe to.&lt;/p&gt;

&lt;p&gt;A minimal locale-scoped RSS endpoint with &lt;code&gt;@astrojs/rss&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;// src/pages/[lang]/rss.xml.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;rss&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;@astrojs/rss&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;getCollection&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:content&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;APIContext&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;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;GET&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;APIContext&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;lang&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;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&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;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog&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="nx"&gt;post&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;post&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="nf"&gt;startsWith&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="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rss&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="s2"&gt;`My Blog (&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="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="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Latest articles in this language&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;site&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;site&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&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;post&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;cleanSlug&lt;/span&gt; &lt;span class="o"&gt;=&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;id&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;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="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="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="nx"&gt;post&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;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;link&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;/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cleanSlug&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="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;Then reference each locale's feed with a &lt;code&gt;&amp;lt;link rel="alternate" type="application/rss+xml"&amp;gt;&lt;/code&gt; tag in your layout, scoped to the current locale. Subscribers stay in their language; aggregators get a clean per-locale stream.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;llms.txt&lt;/code&gt;: The One File Where i18n Doesn't Belong
&lt;/h3&gt;

&lt;p&gt;To provide LLMs with a concise summary of your site, the &lt;a href="https://llmstxt.org/" rel="noopener noreferrer"&gt;llms.txt&lt;/a&gt; proposal suggests using a standard &lt;code&gt;/llms.txt&lt;/code&gt; path. AI agents fetch it to get a high-level map of what your site is and&lt;br&gt;
where the important content lives.&lt;/p&gt;

&lt;p&gt;The interesting question for a multilingual site: do you serve &lt;code&gt;llms.txt&lt;/code&gt; per locale (&lt;code&gt;/en/llms.txt&lt;/code&gt;, &lt;code&gt;/de/llms.txt&lt;/code&gt;), or one file at the root?&lt;/p&gt;

&lt;p&gt;The clean answer is one file, English, at the root. There's no reason to localize &lt;code&gt;llms.txt&lt;/code&gt;, and several reasons not to.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI agents already translate.&lt;/strong&gt; A German-language query against an English &lt;code&gt;llms.txt&lt;/code&gt; costs the agent nothing - modern LLMs handle cross-language summarization natively. There's no comprehension gap to bridge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent doesn't follow &lt;code&gt;Accept-Language&lt;/code&gt; the way browsers do.&lt;/strong&gt; &lt;code&gt;llms.txt&lt;/code&gt; is a meta-document, not user-facing content. The locale-detection pipeline doesn't apply to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance cost multiplies for negligible gain.&lt;/strong&gt; Every product description, every blog post summary, every section header now needs to stay in sync across N locales. The first time a translation lags, the agent's mental map of your site fragments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your real content is still localized.&lt;/strong&gt; &lt;code&gt;llms.txt&lt;/code&gt; points to your real pages, which can be in whatever locale the agent (or its user) needs. Locale-routing happens at the destination, not at the index.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal &lt;code&gt;llms.txt&lt;/code&gt; endpoint generates from your default-locale blog collection plus your project metadata:&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/llms.txt.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;getCollection&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:content&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;APIContext&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SITE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;My Project&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;SITE_DESCRIPTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Edge-native developer tools&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;DEFAULT_LOCALE&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="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;GET&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;APIContext&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;posts&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;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog&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;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;posts&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;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;p&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="nf"&gt;startsWith&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;DEFAULT_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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&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;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&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;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;())&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="mi"&gt;10&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;body&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;SITE_NAME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE_DESCRIPTION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n## Recent Articles\n\n`&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;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;recent&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;cleanSlug&lt;/span&gt; &lt;span class="o"&gt;=&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;id&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;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="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="nx"&gt;body&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;post&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;title&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&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="s2"&gt;/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cleanSlug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/)\n`&lt;/span&gt;
    &lt;span class="nx"&gt;body&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;post&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;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n`&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="nx"&gt;body&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One file, one language, zero locale-detection logic. The simplest part of your SEO surface in a multilingual setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  When Standard Plugins Stop Working
&lt;/h3&gt;

&lt;p&gt;Most of what we've covered so far works with &lt;code&gt;@astrojs/sitemap&lt;/code&gt;, &lt;code&gt;@astrojs/rss&lt;/code&gt;, and a layout that emits &lt;code&gt;hreflang&lt;/code&gt; for every configured locale. That's enough for sites where every page exists in every locale and your URL structure is uniform.&lt;/p&gt;

&lt;p&gt;As soon as any of those assumptions stops holding - partial translations, content-fallback rendering (the user's UI is in Spanish, but this specific blog post is only available in English), distinct UI and content locales - the standard integrations start producing wrong output. &lt;/p&gt;

&lt;p&gt;They emit alternate URLs for non-existent translations. They list sitemap entries for pages that fall back. They put fallback content into the wrong-language RSS feed.&lt;/p&gt;

&lt;p&gt;For EdgeKits.dev I ended up writing custom sitemap, RSS, and &lt;code&gt;llms.txt&lt;/code&gt; endpoints that walk the actual content collections, check whether each translation exists, and only emit entries for pages that actually exist in the requested locale. &lt;/p&gt;

&lt;p&gt;They sit alongside an &lt;code&gt;altLocales&lt;/code&gt;-aware &lt;code&gt;SeoHreflangs&lt;/code&gt; component and a &lt;code&gt;NoIndex&lt;/code&gt; companion that fires whenever a content fallback kicks in. &lt;/p&gt;

&lt;p&gt;The full implementations are open-source in the &lt;a href="https://github.com/EdgeKits/astro-edgekits-core" rel="noopener noreferrer"&gt;EdgeKits Core repo&lt;/a&gt;, and the architectural reasoning behind the UI/content locale split that necessitates them is covered in Part 1 of the &lt;em&gt;Edge-Native i18n&lt;/em&gt; series (linked in the &lt;em&gt;Where the Full Implementation Lives&lt;/em&gt; chapter below).&lt;/p&gt;

&lt;p&gt;The principle generalizes even if my specific implementation is opinionated: as soon as your multilingual setup stops being uniform, your SEO components have to reason about your actual content shape, not about your config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 2 - Localizing Content with Astro Content Collections
&lt;/h2&gt;

&lt;p&gt;For full-page content - blog posts, documentation, MDX articles, long-form marketing copy - the right answer is Astro Content Collections. They're the type-safe, build-aware Astro primitive for managing collections of documents, and they have native conventions for handling locale variants.&lt;/p&gt;

&lt;h3&gt;
  
  
  If You're Building Documentation, Use Starlight
&lt;/h3&gt;

&lt;p&gt;If your content is documentation, stop reading this section and use &lt;a href="https://starlight.astro.build/" rel="noopener noreferrer"&gt;Starlight&lt;/a&gt;. It's Astro's documentation theme, ships with i18n built-in (locale-prefixed routing, sidebar translation, language switcher, fallback handling), and configuration is essentially pasting your locale codes into a config object. &lt;/p&gt;

&lt;p&gt;For most projects, there's no payoff in hand-rolling docs i18n on Content Collections compared to using Starlight directly.&lt;/p&gt;

&lt;p&gt;For everything else - blogs, marketing pages, MDX articles outside a docs site - keep going.&lt;/p&gt;

&lt;h3&gt;
  
  
  Folder Layout
&lt;/h3&gt;

&lt;p&gt;Two conventions, both supported:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-locale subdirectories.&lt;/strong&gt; One folder per locale inside the collection. Easiest to track which posts have which translations at a glance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/content/blog/
├── en/
│   ├── post-1.mdx
│   └── post-2.mdx
├── es/
│   └── post-1.mdx
└── de/
    └── post-1.mdx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Flat with a &lt;code&gt;lang&lt;/code&gt; field.&lt;/strong&gt; All posts in one folder, locale lives in the filename or in frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/content/blog/
├── post-1.en.mdx
├── post-1.es.mdx
├── post-1.de.mdx
└── post-2.en.mdx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subdirectory convention is the default I'd recommend - visually obvious which posts have a German translation and which don't. The flat convention works for tightly translated sites where every post exists in every locale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema with Zod and the Content Layer
&lt;/h3&gt;

&lt;p&gt;Define the collection in &lt;code&gt;src/content.config.ts&lt;/code&gt;. The Content Layer API (Astro 5+) uses a &lt;code&gt;loader&lt;/code&gt; plus a Zod schema:&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/content.config.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;defineCollection&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;astro:content&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;glob&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/loaders&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;blog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.{md,mdx}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./src/content/blog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&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;object&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="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;description&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;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;pubDate&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="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;tags&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;array&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;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;([]),&lt;/span&gt;
    &lt;span class="na"&gt;heroImage&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;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&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;collections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;blog&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typo in &lt;code&gt;pubDate&lt;/code&gt; fails the build with a precise error pointing at the file. Type-safety is one of those Astro features that doesn't sound exciting until you've debugged a multilingual content tree without it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic Routing with &lt;code&gt;getStaticPaths&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A single dynamic route handles every (locale, slug) combination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/pages/[lang]/blog/[...slug].astro

import { getCollection, render } from 'astro:content'

export async function getStaticPaths() {
  const allPosts = await getCollection('blog')

  return allPosts.map((post) =&amp;gt; {
    const [lang, ...slugParts] = post.id.split('/')
    return {
      params: { lang, slug: slugParts.join('/') || undefined },
      props: { post },
    }
  })
}

const { post } = Astro.props
const { Content } = await render(post)
---

&amp;lt;html lang={post.id.split('/')[0]}&amp;gt;
  &amp;lt;h1&amp;gt;{post.data.title}&amp;lt;/h1&amp;gt;
  &amp;lt;Content /&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;For&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;rendered &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`output: 'server'`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;equivalent&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;fetching&lt;/span&gt; &lt;span class="nx"&gt;directly&lt;/span&gt; &lt;span class="nx"&gt;via&lt;/span&gt; &lt;span class="s2"&gt;`getEntry('blog', `&lt;/span&gt;&lt;span class="nx"&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="sr"&gt;/${slug}`&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;` inside the page handler&lt;/span&gt;&lt;span class="err"&gt;.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a reader hits &lt;code&gt;/es/blog/post-3&lt;/code&gt; and no Spanish version exists, the right pattern is graceful fallback - try the requested locale, fall back to the default, mark the rendered fallback page &lt;code&gt;noindex&lt;/code&gt; (covered in the SEO section above). The wrong pattern is silently 404-ing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plugging Content Collections into a Headless CMS
&lt;/h3&gt;

&lt;p&gt;Content Collections aren't married to local Markdown files. The Content Layer API supports custom loaders that fetch from Sanity, Contentful, Strapi, or any external CMS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineCollection&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;astro:content&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;blog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;loader&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="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;res&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-cms.example/api/posts&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;posts&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;res&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;posts&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="na"&gt;post&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="na"&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;post&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;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;post&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="na"&gt;schema&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;object&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;Your content team edits in a polished CMS UI, your Astro build pulls translations on the fly, and the workflow looks clean from every angle.&lt;/p&gt;

&lt;p&gt;There's a footnote.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "No-Deploy Updates" Half-Truth
&lt;/h3&gt;

&lt;p&gt;Most CMS-integration tutorials present this setup as "translators can update content without a developer running a deploy." That's true for the &lt;em&gt;translator's&lt;/em&gt; experience - they click Save and walk away. It's not true for your &lt;em&gt;infrastructure&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Content Collections are a build-time primitive. Data is collected, validated, and frozen during &lt;code&gt;astro build&lt;/code&gt;. For new content to appear on the live site, the build has to run again. Your CMS integration handles this by firing a webhook on every save, which triggers your CI pipeline to redeploy the entire site.&lt;/p&gt;

&lt;p&gt;This works fine when content edits are infrequent. It works less fine when a content team starts iterating: 30 typo fixes during a launch, 50 small copy tweaks for a campaign, every save triggering a 90-second build, builds queueing up, the next deploy landing 8 minutes after the edit. The "no-deploy updates" pitch hides what is in fact a deploy treadmill.&lt;/p&gt;

&lt;p&gt;We come back to this in Level 6 - it's one of the architectural walls that pushes serious multilingual sites toward runtime translation storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 3 - Localizing the UI: The Official &lt;code&gt;ui.ts&lt;/code&gt; Recipe
&lt;/h2&gt;

&lt;p&gt;Astro has an officially-recommended pattern for UI strings - button labels, error messages, navigation copy, microcopy - and it isn't an npm package. It's a hand-written TypeScript module: a dictionary plus two utility functions. Most projects can ship a multilingual UI on this pattern and never need anything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why It's Not From a Library
&lt;/h3&gt;

&lt;p&gt;A recurring confusion when reading Astro i18n tutorials is that examples freely reference &lt;code&gt;useTranslations&lt;/code&gt; as if it were imported from somewhere. It isn't. The &lt;a href="https://docs.astro.build/en/recipes/i18n/" rel="noopener noreferrer"&gt;official Astro i18n recipe&lt;/a&gt; has you write &lt;code&gt;useTranslations&lt;/code&gt; yourself, in about ten lines of TypeScript. There's no &lt;code&gt;@astro/i18n&lt;/code&gt; package to install. That's the whole point - Astro's position is that for UI strings, you don't need a runtime library; you need a typed object and a key lookup.&lt;/p&gt;

&lt;p&gt;If you've been searching npm for &lt;code&gt;useTranslations&lt;/code&gt; and finding nothing, this is why.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ui.ts&lt;/code&gt; - A Type-Safe Dictionary as a Module
&lt;/h3&gt;

&lt;p&gt;Create the dictionary as a &lt;code&gt;const&lt;/code&gt; object. The &lt;code&gt;as const&lt;/code&gt; assertion is what gives you autocomplete on translation keys:&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/i18n/ui.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;defaultLang&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;languages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;English&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&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;Español&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&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;Deutsch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ja&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="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;const&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;en&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;nav.home&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;Home&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;nav.about&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;About&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;cta.subscribe&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;Subscribe&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;error.invalid_email&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;Invalid email&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;es&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;nav.home&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;Inicio&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;nav.about&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;Acerca de&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;cta.subscribe&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;Suscribirse&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;error.invalid_email&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;Email no válido&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;de&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;nav.home&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;Startseite&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;nav.about&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;Über&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;cta.subscribe&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;Abonnieren&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// 'error.invalid_email' intentionally missing - falls back to English at runtime&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dot-notation key style (&lt;code&gt;nav.home&lt;/code&gt; rather than nested objects) is a flat-key convention that simplifies type derivation; deeply nested objects work too if you prefer. The &lt;code&gt;de&lt;/code&gt; locale is intentionally missing one key here to demonstrate fallback - the helper below handles it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;useTranslations(lang)&lt;/code&gt; and &lt;code&gt;getLangFromUrl(url)&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Two helpers, in &lt;code&gt;src/i18n/utils.ts&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;// src/i18n/utils.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;ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defaultLang&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;./ui&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;getLangFromUrl&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="nx"&gt;URL&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&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;lang&lt;/span&gt;&lt;span class="p"&gt;]&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="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;return&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;ui&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="k"&gt;as&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&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;defaultLang&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;useTranslations&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="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ui&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;t&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="nf"&gt;keyof &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&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;defaultLang&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ui&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="k"&gt;as&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;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;ui&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;defaultLang&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;code&gt;getLangFromUrl&lt;/code&gt; parses the locale from the URL path. &lt;code&gt;useTranslations&lt;/code&gt; returns a &lt;code&gt;t()&lt;/code&gt; function that looks up a key in the requested locale and falls back to the default if missing. That's the entire library.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using It in &lt;code&gt;.astro&lt;/code&gt; Pages and Layouts
&lt;/h3&gt;

&lt;p&gt;In any &lt;code&gt;.astro&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { getLangFromUrl, useTranslations } from '@/i18n/utils'

const lang = getLangFromUrl(Astro.url)
const t = useTranslations(lang)
---

&amp;lt;nav&amp;gt;
  &amp;lt;a href={`/${lang}/`}&amp;gt;{t('nav.home')}&amp;lt;/a&amp;gt;
  &amp;lt;a href={`/${lang}/about/`}&amp;gt;{t('nav.about')}&amp;lt;/a&amp;gt;
&amp;lt;/nav&amp;gt;

&amp;lt;button&amp;gt;{t('cta.subscribe')}&amp;lt;/button&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. SSR-rendered, no client JavaScript, full type safety on the key. Mistype &lt;code&gt;t('nav.hom')&lt;/code&gt; and the build fails. Delete &lt;code&gt;nav.home&lt;/code&gt; from the dictionary and every callsite turns red in your editor.&lt;/p&gt;

&lt;p&gt;You can swap &lt;code&gt;getLangFromUrl&lt;/code&gt; for &lt;code&gt;Astro.currentLocale&lt;/code&gt;, since the native i18n routing already exposes the resolved locale - they're equivalent for any URL the routing layer knows about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the Recipe Stops Scaling
&lt;/h3&gt;

&lt;p&gt;The pattern is excellent up to a point. The point looks something like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interpolation.&lt;/strong&gt; You want &lt;code&gt;Welcome back, {name}!&lt;/code&gt; with safe variable substitution. Doable as a &lt;code&gt;fmt()&lt;/code&gt; helper - but it's more code you write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plurals.&lt;/strong&gt; &lt;code&gt;1 item&lt;/code&gt; vs &lt;code&gt;5 items&lt;/code&gt;, with rules that differ per language. &lt;code&gt;Intl.PluralRules&lt;/code&gt; solves it, with more infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Namespaces.&lt;/strong&gt; A dictionary that started at 50 keys becomes 500. Splitting it into &lt;code&gt;common.json&lt;/code&gt;, &lt;code&gt;landing.json&lt;/code&gt;, &lt;code&gt;dashboard.json&lt;/code&gt; with per-page loading is doable but means writing a loader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React Islands.&lt;/strong&gt; An interactive form needs translations on the client. You either prop-drill the dictionary in (heavy) or refactor toward selective string passing (lighter, more boilerplate).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editing without a deploy.&lt;/strong&gt; The dictionary lives in &lt;code&gt;src/&lt;/code&gt;, so every typo fix is a deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is solvable with bespoke code. At some volume of solving, you've effectively rebuilt a small i18n library - and that's the moment to look at what already exists.&lt;/p&gt;

&lt;p&gt;The next level surveys what already exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 4 - The Library Landscape: Candid Tour
&lt;/h2&gt;

&lt;p&gt;When the recipe pattern from Level 3 stops covering your needs, you reach for a library. The Astro i18n library ecosystem in 2026 has roughly five names worth knowing - three of them are alive (with varying maintenance pulses), two have effectively gone quiet, and only one is actively the right answer for most new projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to Read This Section
&lt;/h3&gt;

&lt;p&gt;Each library here answers a version of the same question - "what if &lt;code&gt;ui.ts&lt;/code&gt; isn't enough?" - but the answers vary on dimensions that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where the translations end up.&lt;/strong&gt; In your client bundle? In your server bundle? Loaded dynamically at runtime?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether tree-shaking actually removes unused strings.&lt;/strong&gt; Some libraries claim it; some deliver it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How updates ship.&lt;/strong&gt; Compiled into code (deploy required) vs loaded as data (no deploy).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance pulse.&lt;/strong&gt; Recent releases, active issues, alignment with current Astro versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each block below covers one library against those dimensions, ends with a short verdict, and a side-by-side table closes the section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Paraglide JS (Inlang)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://inlang.com/m/gerre34r/library-inlang-paraglideJs" rel="noopener noreferrer"&gt;Paraglide JS&lt;/a&gt; is the i18n library most worth knowing in 2026. It's compiler-based: at build time, your translation JSON files compile into individual TypeScript message functions, one per key. &lt;/p&gt;

&lt;p&gt;Your bundler (Vite, in Astro's case) then tree-shakes the ones you don't use out of the client output. &lt;/p&gt;

&lt;p&gt;The result is a client bundle that contains exactly the strings the page renders - usually a few KB instead of tens of KB.&lt;/p&gt;

&lt;p&gt;Setup on Paraglide v2 (no Astro adapter package required - v2 ships everything in &lt;code&gt;@inlang/paraglide-js&lt;/code&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;// astro.config.mjs&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;defineConfig&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/config&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;paraglideVitePlugin&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;@inlang/paraglide-js&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;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;defaultLocale&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;locales&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;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;es&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;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;span class="na"&gt;vite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nf"&gt;paraglideVitePlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./project.inlang&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;outdir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./src/paraglide&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Translations live as JSON in the inlang project directory:&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;messages/en.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;"greeting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello, {name}!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subscribe_button"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Subscribe"&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;Usage in components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import * as m from '@/paraglide/messages'
---

&amp;lt;h1&amp;gt;{m.greeting({ name: 'Gary' })}&amp;lt;/h1&amp;gt;
&amp;lt;button&amp;gt;{m.subscribe_button()}&amp;lt;/button&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each message is a typed function. Mistype &lt;code&gt;m.greting&lt;/code&gt; and the build fails. Forget the &lt;code&gt;name&lt;/code&gt; parameter and the build fails. Update &lt;code&gt;en.json&lt;/code&gt; and the type for &lt;code&gt;m.greeting&lt;/code&gt;'s parameter regenerates automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The good.&lt;/strong&gt; Strongest client tree-shaking of any current Astro i18n library. Type-safe message functions across the whole stack. Native interpolation, native plurals via the JSON message format, namespaces via JSON file organization. Active development, frequent releases, single-package install in v2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The footnote.&lt;/strong&gt; Tree-shaking saves the &lt;em&gt;client&lt;/em&gt; bundle, not the &lt;em&gt;server&lt;/em&gt; bundle. On the server (or the Worker), the compiled code still has to be loaded into memory for SSR to call any message function. With many locales and many namespaces, that cost adds up - your server bundle grows even as your client bundle stays lean. We come back to this in Level 6.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict.&lt;/strong&gt; If you've outgrown the &lt;code&gt;ui.ts&lt;/code&gt; recipe, Paraglide is the default answer. It's the closest thing in 2026 to a "use this and don't think about i18n again" choice for the UI layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;astro-i18next&lt;/code&gt; (yassinedoghri)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/yassinedoghri/astro-i18next" rel="noopener noreferrer"&gt;&lt;code&gt;astro-i18next&lt;/code&gt;&lt;/a&gt; was the most popular Astro i18n library through 2023. It's a thin Astro wrapper around the &lt;a href="https://www.i18next.com/" rel="noopener noreferrer"&gt;i18next&lt;/a&gt; ecosystem - runtime-loaded JSON dictionaries, full i18next plugin compatibility, generated translated routes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict in 2026: archived in practice.&lt;/strong&gt; The last published version is &lt;code&gt;1.0.0-beta.21&lt;/code&gt;, released in March 2023 - over three years ago at the time of this writing. The repository has accumulated open issues against newer Astro versions without responses. &lt;/p&gt;

&lt;p&gt;Don't start a new project on it. If you're maintaining a project that already uses it, the migration paths are either Paraglide (smaller client bundle, fewer dependencies) or hand-rolling the recipe pattern from Level 3 (zero dependencies, full control).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;astro-i18next&lt;/code&gt; still ranks highly for "Astro i18n" searches, which is part of why it's worth covering - readers who land on it from old tutorials should know it's no longer the right answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;astro-i18n-aut&lt;/code&gt; (jlarmstrongiv)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/jlarmstrongiv/astro-i18n-aut" rel="noopener noreferrer"&gt;&lt;code&gt;astro-i18n-aut&lt;/code&gt;&lt;/a&gt; takes a different approach: it auto-generates locale-prefixed routes for static sites without using middleware. You configure your locales and the integration creates &lt;code&gt;/es/about&lt;/code&gt;, &lt;code&gt;/de/about&lt;/code&gt;, etc. as build artifacts, no &lt;code&gt;[locale]&lt;/code&gt; dynamic routes required.&lt;/p&gt;

&lt;p&gt;The library is alive but quiet - current version &lt;code&gt;0.7.3&lt;/code&gt;, last released at the start of 2025. Maintained, but not under active feature development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use case.&lt;/strong&gt; Strictly static (SSG-only) sites where you can't or don't want to use middleware, and where you want a simpler routing model than Astro's native dynamic-route pattern. &lt;/p&gt;

&lt;p&gt;For sites that already use middleware (locale detection, custom redirects, server-rendered pages), there's no reason to reach for this - native i18n routing covers what &lt;code&gt;astro-i18n-aut&lt;/code&gt; does with more flexibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;astro-i18n&lt;/code&gt; (Alexandre-Fernandez)
&lt;/h3&gt;

&lt;p&gt;A separate, less-known TypeScript-first runtime library by Alexandre Fernandez - &lt;a href="https://github.com/Alexandre-Fernandez/astro-i18n" rel="noopener noreferrer"&gt;&lt;code&gt;astro-i18n&lt;/code&gt;&lt;/a&gt;, not to be confused with yassinedoghri's &lt;code&gt;astro-i18next&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;Current version &lt;code&gt;2.2.4&lt;/code&gt;, last released in January 2024 - over two years ago at the time of writing. Provides namespaced translations, interpolation, plurals via runtime API, and dedicated CLI tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict.&lt;/strong&gt; Two-plus years without a release puts this in the same "use at your own risk" bucket as &lt;code&gt;astro-i18next&lt;/code&gt; - the API ergonomics are different (TypeScript-first, more elegant runtime), but the maintenance pulse is similarly thin. If you're already running it on a stable Astro version, fine. For new projects, Paraglide is the cleaner pick.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;@astrolicious/i18n&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/astrolicious/i18n" rel="noopener noreferrer"&gt;&lt;code&gt;@astrolicious/i18n&lt;/code&gt;&lt;/a&gt; is a third-party integration that bundles its own routing layer, translated paths (different slugs per locale), and translation utilities. Worth mentioning because it surfaces in some search results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caveat.&lt;/strong&gt; It's incompatible with Astro's native i18n routing - you pick one or the other, and most of this article's foundation work assumes you're on the native one. Useful in projects where translated slugs are non-negotiable; otherwise not the right starting point in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  Side-by-Side: How They Compare
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Client bundle&lt;/th&gt;
&lt;th&gt;Server bundle&lt;/th&gt;
&lt;th&gt;Type-safe keys&lt;/th&gt;
&lt;th&gt;Interpolation / plurals&lt;/th&gt;
&lt;th&gt;Namespaces&lt;/th&gt;
&lt;th&gt;Runtime updates&lt;/th&gt;
&lt;th&gt;Maintained (2026)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Paraglide JS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tree-shaken (lean)&lt;/td&gt;
&lt;td&gt;Compiled in (grows)&lt;/td&gt;
&lt;td&gt;✅ Generated functions&lt;/td&gt;
&lt;td&gt;✅ Native JSON format&lt;/td&gt;
&lt;td&gt;✅ JSON files&lt;/td&gt;
&lt;td&gt;❌ Build required&lt;/td&gt;
&lt;td&gt;✅ Active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;astro-i18next&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runtime JSON&lt;/td&gt;
&lt;td&gt;Runtime JSON&lt;/td&gt;
&lt;td&gt;⚠️ Loose (string keys)&lt;/td&gt;
&lt;td&gt;✅ via i18next&lt;/td&gt;
&lt;td&gt;✅ via i18next&lt;/td&gt;
&lt;td&gt;⚠️ Limited&lt;/td&gt;
&lt;td&gt;❌ Archived (2023)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;astro-i18n-aut&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Compiled into pages&lt;/td&gt;
&lt;td&gt;n/a (SSG)&lt;/td&gt;
&lt;td&gt;⚠️ Loose&lt;/td&gt;
&lt;td&gt;⚠️ Basic&lt;/td&gt;
&lt;td&gt;⚠️ File-based&lt;/td&gt;
&lt;td&gt;❌ Build required&lt;/td&gt;
&lt;td&gt;⚪ Quiet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;code&gt;astro-i18n&lt;/code&gt;&lt;/strong&gt; (Fernandez)&lt;/td&gt;
&lt;td&gt;Runtime JSON&lt;/td&gt;
&lt;td&gt;Runtime JSON&lt;/td&gt;
&lt;td&gt;✅ Generated types&lt;/td&gt;
&lt;td&gt;✅ Built-in&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;td&gt;⚠️ Limited&lt;/td&gt;
&lt;td&gt;❌ Inactive (2024)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;@astrolicious/i18n&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runtime + routing&lt;/td&gt;
&lt;td&gt;Runtime + routing&lt;/td&gt;
&lt;td&gt;✅ Generated types&lt;/td&gt;
&lt;td&gt;✅ Built-in&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;td&gt;⚠️ Limited&lt;/td&gt;
&lt;td&gt;⚪ Maintained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hand-rolled &lt;code&gt;ui.ts&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Compiled in (grows)&lt;/td&gt;
&lt;td&gt;Compiled in (grows)&lt;/td&gt;
&lt;td&gt;✅ via &lt;code&gt;as const&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;❌ Roll your own&lt;/td&gt;
&lt;td&gt;⚠️ Roll your own&lt;/td&gt;
&lt;td&gt;❌ Build required&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern across the table: every library here is fundamentally a &lt;em&gt;build-time&lt;/em&gt; solution. Translations end up baked into either the client bundle, the server bundle, or both. None of them solves the "translators edit, no deploy fires" problem - for that you need a different architecture entirely.&lt;/p&gt;

&lt;p&gt;That's the bridge to Level 5 (where forms expose another set of bundle-bloat tradeoffs) and Level 6 (where every library on this table hits the same architectural ceiling).&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 5 - The Form Problem: Zod, RHF, and Why You're Shipping Your Dictionary
&lt;/h2&gt;

&lt;p&gt;Forms are where every i18n library quietly fails. The moment you wire up Zod + React Hook Form for client-side validation, the temptation is to put translated error messages directly in the schema. That one decision drags your entire dictionary into the client bundle - regardless of which library from Level 4 you picked.&lt;/p&gt;

&lt;p&gt;There's a pattern that survives this, and it works on top of any of the L3/L4 stacks.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Anti-Pattern
&lt;/h3&gt;

&lt;p&gt;The natural-looking version is the one most tutorials show:&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;// validation.ts - anti-pattern&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;t&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;@/i18n/utils&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;newsletterSchema&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="nf"&gt;object&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="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;error&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;error.invalid_email&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;name&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;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&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;error.name_too_short&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks clean. The schema works on the server and on the client; translations are in the right place. The problem is timing: &lt;code&gt;t('error.invalid_email')&lt;/code&gt; resolves &lt;em&gt;when the schema is constructed&lt;/em&gt;, and for live client-side validation the schema has to be constructed in the browser. &lt;/p&gt;

&lt;p&gt;So the entire error portion of your dictionary ships to the client too. Tree-shaking can't help - every error string is reachable from the validation entry point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Domain Error Codes - Validation Returns Contracts, Not Prose
&lt;/h3&gt;

&lt;p&gt;The pattern that keeps the dictionary out of the client: have Zod return &lt;em&gt;codes&lt;/em&gt;, not &lt;em&gt;prose&lt;/em&gt;. The schema speaks in domain terms (&lt;code&gt;INVALID_EMAIL&lt;/code&gt;, &lt;code&gt;NAME_TOO_SHORT&lt;/code&gt;), and translation happens later, at the rendering boundary.&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/lib/validation.ts (Zod 4 syntax)&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newsletterSchema&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="nf"&gt;object&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="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;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;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;name&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;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&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;NAME_TOO_SHORT&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="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&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;NAME_TOO_LONG&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;type&lt;/span&gt; &lt;span class="nx"&gt;NewsletterInput&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;newsletterSchema&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;&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%2F03zkr7v2tphietlbgswr.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%2F03zkr7v2tphietlbgswr.jpg" alt="The Astro i18n form-validation anti-pattern that leaks the entire translation dictionary into the React client bundle, contrasted with the fix: Zod schemas return domain error codes like INVALID_EMAIL, and translation happens at the render boundary inside the Astro Action." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This schema imports nothing from the i18n layer. It's reusable on the server, on the client, and in any test runner - and it ships zero translation code to the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Translate at the Render Boundary
&lt;/h3&gt;

&lt;p&gt;The translation happens at the render boundary. The component that displays the error converts the code to a localized string using a small translation map specific to that form's vocabulary, passed in as a prop. &lt;/p&gt;

&lt;p&gt;The form's render layer is the only place in the entire stack that needs to know what &lt;code&gt;INVALID_EMAIL&lt;/code&gt; says in Spanish. (The dedicated &lt;em&gt;Edge-Native i18n&lt;/em&gt; series on edgekits.dev - linked in &lt;em&gt;Resources &amp;amp; Further Reading&lt;/em&gt; below - calls this same idea &lt;strong&gt;Final Mile Localization&lt;/strong&gt;, and goes deeper into the React Hook Form integration in &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;.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Astro Action + React Hook Form
&lt;/h3&gt;

&lt;p&gt;The React island that renders the form:&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/NewsletterForm.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;useForm&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-hook-form&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;zodResolver&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;@hookform/resolvers/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;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;newsletterSchema&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;NewsletterInput&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;@/lib/validation&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;FormStrings&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;email_label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;name_label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;errors&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;// localized map, keyed by code&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;NewsletterForm&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="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;FormStrings&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;register&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;formState&lt;/span&gt;&lt;span class="p"&gt;:&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="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useForm&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;NewsletterInput&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;resolver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;zodResolver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newsletterSchema&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;onBlur&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localize&lt;/span&gt; &lt;span class="o"&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="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="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="nx"&gt;t&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;code&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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="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;onSubmit&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NewsletterInput&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;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="nf"&gt;newsletter&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;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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="c1"&gt;// Domain errors thrown as ActionError carry the code in error.message.&lt;/span&gt;
    &lt;span class="c1"&gt;// (Zod input-validation errors arrive on `error.fields` - usually&lt;/span&gt;
    &lt;span class="c1"&gt;//  caught client-side by zodResolver before reaching the network.)&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="nf"&gt;setError&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&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;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="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;form&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="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onSubmit&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;label&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;email_label&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;input&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;register&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&lt;/span&gt;&lt;span class="dl"&gt;'&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;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="nf"&gt;localize&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;email&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="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="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;label&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;label&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;name_label&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;input&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&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;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="nf"&gt;localize&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;name&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="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="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;label&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;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;'submit'&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;submit&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;form&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 wrapping &lt;code&gt;.astro&lt;/code&gt; component builds &lt;code&gt;t&lt;/code&gt; on the server and injects it as a prop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/components/forms/NewsletterFormWrapper.astro

import { getLangFromUrl, useTranslations } from '@/i18n/utils'
import { NewsletterForm } from './islands/NewsletterForm'

const lang = getLangFromUrl(Astro.url)
const tr = useTranslations(lang)

const formStrings = {
  email_label: tr('form.email_label'),
  name_label: tr('form.name_label'),
  submit: tr('form.submit'),
  errors: {
    INVALID_EMAIL: tr('error.invalid_email'),
    NAME_TOO_SHORT: tr('error.name_too_short'),
    NAME_TOO_LONG: tr('error.name_too_long'),
    EMAIL_ALREADY_REGISTERED: tr('error.email_already_registered'),
  },
}
---

&amp;lt;NewsletterForm client:load t={formStrings} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Astro Action on the server validates with the same schema and returns codes, never prose:&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/index.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;defineAction&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="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;newsletterSchema&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;@/lib/validation&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;newsletter&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;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form&lt;/span&gt;&lt;span class="dl"&gt;'&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;newsletterSchema&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;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&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;if &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;emailExists&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;span class="c1"&gt;// Throw, don't return - Astro Actions only routes thrown&lt;/span&gt;
        &lt;span class="c1"&gt;// ActionErrors to `error` on the client; returned values go to `data`.&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;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="na"&gt;message&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_REGISTERED&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;// ... persist, send confirmation, etc.&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;ok&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server errors travel back as codes; the client's &lt;code&gt;localize()&lt;/code&gt; map handles them identically to Zod's local validation errors. One vocabulary, one translation point, no dictionary in the bundle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Pattern Is Stack-Agnostic
&lt;/h3&gt;

&lt;p&gt;Nothing in this pattern depends on which translation system you use. The Astro wrapper here builds &lt;code&gt;formStrings&lt;/code&gt; from &lt;code&gt;useTranslations&lt;/code&gt; (&lt;code&gt;ui.ts&lt;/code&gt;), but &lt;code&gt;m.error_invalid_email()&lt;/code&gt; from Paraglide, or &lt;code&gt;await fetchTranslations(...)&lt;/code&gt; from a KV runtime (Level 7), would slot into the same place identically. &lt;/p&gt;

&lt;p&gt;The schema speaks codes, the wrapper converts codes to strings, the island renders them. Whatever sits behind the wrapper is invisible to the form.&lt;/p&gt;

&lt;p&gt;Form i18n is a &lt;em&gt;boundary problem&lt;/em&gt;. Once you solve it as a boundary problem, the choice of underlying library stops mattering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 6 - The Glass Ceiling: Where Bundled Approaches Eventually Break
&lt;/h2&gt;

&lt;p&gt;We've spent five levels treating translations as code. That choice has a ceiling. Every library on the table in Level 4 hits the same ceiling at scale; the only differences are which side of the ceiling you hit first, and how loudly.&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%2Fbky42z6wis557igcjeut.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%2Fbky42z6wis557igcjeut.jpg" alt="The four architectural walls of build-time bundled-translation i18n in Astro: the Deploy Treadmill where every typo fix triggers a CI rebuild, the Server Bundle Trap of serverless function size limits, the Dynamic Content Gap where UGC sits outside the compiler, and the HTML Payload Trade-Off." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four walls separate "this scales" from "this doesn't." If your project is going to hit any of them within the next 12 months, the rest of this guide is the exit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wall 1 - Build-Time Updates as a Deployment Treadmill
&lt;/h3&gt;

&lt;p&gt;We touched this in Level 2's CMS half-truth. The full picture: every translation update - typo fix, A/B copy test, marketing tweak - fires a webhook, queues a CI build, runs the full pipeline, and ships a deploy. &lt;/p&gt;

&lt;p&gt;A 90-second build per save sounds fine until a content team starts iterating, at which point you're blocking real-feature deploys behind the next round of "could you add a comma?" rebuilds. &lt;/p&gt;

&lt;p&gt;The pleasant "translators ship without developers" pitch is, in infrastructure terms, a rebuild treadmill.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wall 2 - The Server Bundle Trap
&lt;/h3&gt;

&lt;p&gt;Every library in Level 4 - including Paraglide with its compiler tree-shaking - bakes the full translation set into the &lt;em&gt;server&lt;/em&gt; bundle. &lt;/p&gt;

&lt;p&gt;Tree-shaking saves the client; the server still has to load every message function for SSR. &lt;/p&gt;

&lt;p&gt;Every serverless platform sets a hard ceiling on the size of the deployed function - typically a few megabytes after compression - and translations end up competing with your business logic for that budget. &lt;/p&gt;

&lt;p&gt;Eight locales × three namespaces × 60 KB of translations is roughly 1.4 MB of pure text inside your function. &lt;/p&gt;

&lt;p&gt;As the locale count grows, the cost shows up first as longer cold starts, then as deploy failures when the platform ceiling is reached.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wall 3 - The Dynamic Content Gap
&lt;/h3&gt;

&lt;p&gt;Static-time tools localize what's in your repository. They don't localize what's in your database. User-generated content, dynamic product descriptions, customer-submitted reviews - none of it goes through the i18n compiler. &lt;/p&gt;

&lt;p&gt;If you build a SaaS that has both a marketing site and a UGC layer, you end up maintaining two i18n systems: one for the UI (compiled code) and one for the data (database columns or a translation service). The two share nothing and drift over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wall 4 - The HTML Payload Trade-Off
&lt;/h3&gt;

&lt;p&gt;Flagging this in advance because it's the cost of the architecture we're about to recommend, not the one we're leaving. &lt;/p&gt;

&lt;p&gt;If you move translations off the JavaScript bundle and inject them into the HTML at SSR time (which is what Level 7 does), the HTML response grows to hold what the JS used to hold. &lt;/p&gt;

&lt;p&gt;That's a real trade-off - mitigated by namespace splitting (load only what each page needs), not eliminated. We're explicit about it when we get there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-Diagnosis Checklist
&lt;/h3&gt;

&lt;p&gt;Pick the level that fits your &lt;em&gt;current&lt;/em&gt; shape. Climb only when one of these symptoms applies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Translation edits cost you a deploy more often than once a week&lt;/li&gt;
&lt;li&gt;Your server bundle is over 1 MB and a non-trivial fraction is translation text&lt;/li&gt;
&lt;li&gt;Adding a locale measurably slows your cold start&lt;/li&gt;
&lt;li&gt;You support more than ~5 locales and namespace count is still growing&lt;/li&gt;
&lt;li&gt;User-generated content needs to be localized as well&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zero symptoms: stay where you are. One or two: start watching the metric. Three or more: the next level is the right level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 7 - Edge-Native i18n: Translations as Runtime Data
&lt;/h2&gt;

&lt;p&gt;The exit from the four walls is to stop treating translations as something you ship inside your code. Translations are data: edited like data, stored like data, served like data, on a release cadence that has nothing to do with code deployments. &lt;/p&gt;

&lt;p&gt;The umbrella term I use for this pattern is &lt;strong&gt;edge-native i18n&lt;/strong&gt; - translations stored at the edge, fetched at request time, injected into the rendered HTML.&lt;/p&gt;

&lt;p&gt;One disclosure before we go deeper. The implementation below is built on Cloudflare specifically (Workers + KV + Cache API), because that's my production stack and the platform I've shipped this on. &lt;/p&gt;

&lt;p&gt;Equivalent primitives exist on Vercel (Edge Config, KV), Deno (Deno KV), Netlify (Blobs), and elsewhere - the architectural pattern translates, the specific bindings don't. If you're not on Cloudflare, read the rest of this section as the shape of the solution rather than a copy-paste blueprint.&lt;/p&gt;

&lt;p&gt;The full code - middleware, KV fetcher, build-time scripts, the React Islands handoff - and the architectural reasoning behind every choice are documented in a separate three-part &lt;em&gt;Edge-Native i18n&lt;/em&gt; series on edgekits.dev. &lt;/p&gt;

&lt;p&gt;All links live in the &lt;em&gt;Where the Full Implementation Lives&lt;/em&gt; block at the bottom of this article. The summary that follows is enough to decide whether to read further.&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%2Ffs4y90lwe5ca80xytc9d.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%2Ffs4y90lwe5ca80xytc9d.jpg" alt="The Edge-Native Astro i18n architecture stack: Astro middleware handling routing and SSR on top, Cloudflare Workers as the serverless runtime in the middle, Cloudflare KV as the global key-value translation store at the bottom, with TypeScript type generation feeding the whole stack." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Translations Actually Live
&lt;/h3&gt;

&lt;p&gt;Translations live in Cloudflare KV (or any equivalent edge KV store), not in your repository. Astro middleware fetches the namespaces a page needs at request time, with the Cloudflare Cache API in front of KV to keep latency near zero. &lt;/p&gt;

&lt;p&gt;Translations are injected into Astro props during SSR - the client receives plain HTML with strings already rendered. No client-side i18n library, no JSON download, no hydration mismatch.&lt;/p&gt;

&lt;h3&gt;
  
  
  High-Level Architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request → Worker → Middleware → Cache API check
                                  │
                                  ├── Hit  → cached translations (sub-1ms)
                                  └── Miss → fetch from KV (1–5ms) → fill cache
                                                                       │
                                  Inject translations into Astro props ┘
                                                                       │
                                  Render HTML with strings baked in    ┘
                                                                       │
                                  Respond                              ┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No JSON ships to the browser. No client-side &lt;code&gt;t()&lt;/code&gt; lookup. No hydration mismatch.&lt;/p&gt;

&lt;h3&gt;
  
  
  What It Buys You
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-deploy translation updates.&lt;/strong&gt; A translator edits, KV updates, the next request sees the new text. No webhooks, no build queue, no CI minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifted server-bundle limits.&lt;/strong&gt; Translations don't live in the Worker. Locale count stops affecting cold-start time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render-boundary translation, built in.&lt;/strong&gt; The Level 5 pattern (codes-not-prose) snaps in cleanly - translations arrive at the right boundary already.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilient fallbacks.&lt;/strong&gt; A small &lt;code&gt;DEFAULT_LOCALE&lt;/code&gt; dictionary compiled into the Worker keeps the site rendering even if KV goes offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granular cache invalidation.&lt;/strong&gt; Only the namespaces whose JSON content changed get purged from the edge cache (covered in Part 3 of the series).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Latency Numbers
&lt;/h3&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%2F86ivuv2yww9abaozaroi.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%2F86ivuv2yww9abaozaroi.jpg" alt="The Astro i18n Edge Cache API performance flow on Cloudflare: translations served sub-millisecond on cache hits and fetched from KV only on misses, with cache keys built from project + version + locale + namespaces and Cache-Control: public, s-maxage=... headers." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache hit (95–99% of requests):&lt;/strong&gt; sub-1 ms added per request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache miss, hot KV:&lt;/strong&gt; 1–5 ms. First request in a region after a deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache miss, cold region:&lt;/strong&gt; up to 10–15 ms, rare. Region warms after the first hit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most requests the i18n round-trip is statistically indistinguishable from bundled translations. The remaining few pay a few milliseconds for dynamic-update capability - a trade you make explicitly, not one a library makes for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  What It Costs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare alignment.&lt;/strong&gt; The pattern as shipped is Cloudflare-specific (Workers + KV + Cache API). Equivalents exist on other platforms (Vercel KV, Deno KV, Upstash) but require porting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tooling overhead.&lt;/strong&gt; Scripts to bundle, seed, and migrate translations. EdgeKits Core ships these out of the box; rolling your own is real work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML payload growth.&lt;/strong&gt; Wall 4 from Level 6 - weight that left the JS now lives in the HTML. Mitigated by namespace splitting, not eliminated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Where the Full Implementation Lives
&lt;/h3&gt;

&lt;p&gt;The complete code - middleware, KV fetcher, type-generation pipeline, fallback dictionaries, migration scripts - is open-source as &lt;a href="https://github.com/EdgeKits/astro-edgekits-core" rel="noopener noreferrer"&gt;Astro EdgeKits Core&lt;/a&gt; (MIT, drop-in for any Astro project on Cloudflare).&lt;/p&gt;

&lt;p&gt;The architectural reasoning behind it is split across the three-part &lt;em&gt;Edge-Native i18n&lt;/em&gt; series mentioned above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-1-5b38"&gt;&lt;strong&gt;Part 1&lt;/strong&gt;&lt;/a&gt; covers the middleware, the KV fetch + cache layer, and the &lt;code&gt;uiLocale&lt;/code&gt; / &lt;code&gt;translationLocale&lt;/code&gt; split.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-2-359n"&gt;&lt;strong&gt;Part 2&lt;/strong&gt;&lt;/a&gt; covers Zod + React Hook Form + Astro Actions in this pattern, plus D1 for translating user-generated content.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/garyedgekits/stop-redeploying-to-update-translations-granular-edge-cache-invalidation-with-cloudflare-purge-api-2cm7"&gt;&lt;strong&gt;Part 3&lt;/strong&gt;&lt;/a&gt; covers content-hash cache invalidation and granular purging via the Cloudflare Purge API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've read this far and one of the walls from Level 6 applies, that's where the implementation lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Decision Matrix: Pick the Lowest Level That Doesn't Break
&lt;/h2&gt;

&lt;p&gt;Seven levels, four shapes of project. The matrix below is opinionated - these are the picks I'd make for each archetype, not an exhaustive map of valid options.&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%2F8xxzh7m48vdobqsdq28u.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%2F8xxzh7m48vdobqsdq28u.jpg" alt="Decision matrix for choosing an Astro i18n architecture by project shape: personal blog (native routing + ui.ts), marketing site (Content Collections), mid-size SaaS (Paraglide + Domain Error Codes), Edge SaaS or TMA (KV + Cache API + render-boundary logic). Pick the lowest level that doesn't break under your specific load." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Closing Principle
&lt;/h3&gt;

&lt;p&gt;Pick the lowest level that doesn't break under your specific load. Resist the temptation to over-engineer for problems you don't have yet - the &lt;code&gt;ui.ts&lt;/code&gt; recipe is a perfectly good answer for plenty of production sites. &lt;/p&gt;

&lt;p&gt;Resist the temptation to under-engineer once you're hitting one of the walls in Level 6 - patches accumulate, the pipeline grows, and one of those walls eventually tips over.&lt;/p&gt;

&lt;p&gt;The right answer isn't the most architecturally interesting one. It's the one that matches the problem you actually have today, with a clear migration path to the next level when you outgrow it.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>astro</category>
      <category>webdev</category>
    </item>
    <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://dev.to/garyedgekits/stop-redeploying-to-update-translations-granular-edge-cache-invalidation-with-cloudflare-purge-api-2cm7</link>
      <guid>https://dev.to/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://dev.to/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-2-359n</link>
      <guid>https://dev.to/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://dev.to/garyedgekits/stop-shipping-translations-to-the-client-edge-native-i18n-with-astro-cloudflare-part-1-5b38</link>
      <guid>https://dev.to/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>
