Conceptual illustration of a spaceship jettisoning heavy JSON translations and client-side bloat, representing the shift to zero-JS Edge-Native i18n architecture.

Stop Shipping Translations to the Client: Edge-Native i18n with Astro & Cloudflare (Part 1)

Implementing Edge-native i18n using Astro Middleware and Cloudflare Workers KV.

#astro #cloudflare workers #i18n #performance #typescript #ssr #system design #serverless
🎧

Audio Deep Dive

Too busy to read? Listen to a 15-minute debate on this Architecture Deep Dive (generated by NotebookLM).

When I started building EdgeKits.dev, the stack felt like a cheat code for 2026.

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.

Then the magic broke.

I hit the wall of Astro Internationalization (i18n).

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

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.

Why the “Perfect Stack” Cracked

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

I tried fitting standard solutions into the constraints of Cloudflare Workers and hit three fundamental walls.

Comparison of traditional client-side JSON bundle bloat versus Edge-Native pre-rendered HTML in Astro

1. The “Fat Worker” Problem (Bundle Bloat)

Most libraries want you to import JSON files directly into your code. Fine for a static site. May be critical for a Worker.

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.

I didn’t want adding a new language to slow down my entire API.

2. Hydration Hell

This is the classic Astro + React conflict.

The Server (SSR) renders English because the URL says so. The Client (React Island) wakes up, checks localStorage, sees “German,” and panic-renders.

The result: 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.

3. CLS and the “Jump”

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.

The Paradigm Shift: Translations are Data, Not Code

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.

1. The “Fat Worker” Paradox 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.

On Cloudflare, you have a hard limit on script size (1MB 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.

2. The Dynamic Content Gap 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).

3. High-Complexity Maintenance 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.

I needed something else. I wanted i18n to behave like a Content Delivery Service:

  • The Edge is the Source of Truth: It decides the language based on URL, cookies, and headers.
  • The Client is “Dumb”: It receives ready-to-render data. No guessing.
  • Zero-JS Payload: Translations are injected into HTML or component props during SSR.

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. That left me with only one option: build a bespoke architecture from scratch.

Edge-Native i18n Architecture: Inverting Control

In a traditional SPA, the client is the boss. It loads, checks navigator.language, and issues a network request for a translation file. This is a “Pull” architecture.

I flipped this model. In EdgeKits, the Client is dumb. It doesn’t guess the language - and it certainly doesn’t fetch it over the network. It receives the language as a constraint from the Server.

This is a “Push” architecture.

The Request Flow

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.

Here is the lifecycle of a request:

  1. Interception: The request hits the Cloudflare Worker.
  2. Resolution: Our Middleware analyzes the request immediately - checking URL paths (/de/), cookies, and headers.
  3. Data Fetch: The Worker checks the Edge Cache. If it’s a miss, it fetches from KV and hydrates the cache.
  4. Injection: Translations are injected directly into Astro props.
  5. Rendering: Astro generates HTML with strings baked in.
Astro Middleware request pipeline showing uiLocale detection and translationLocale normalization

By the time the React Island wakes up on the client, the text is already there. No useEffect. No loading spinners. The component hydrates over HTML that matches its props exactly.

The Core Logic: Decoupling Intent from Data

The critical architectural decision here was to split the concept of “Current Locale” into two distinct variables.

Most i18n frameworks tightly couple the URL to the Data.

If a user visits /ja/ (Japanese) but you haven’t deployed the translation files yet, standard adapters usually force a 302 Redirect back to English. This changes the URL and disrupts the user’s intent.

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

I introduced a “Split Brain” model in the request context to prevent this:

Split Brain architecture decoupling user intent from data availability to prevent runtime crashes
  1. uiLocale (The Intent): What the user wants to see. This controls the URL (/ja/about), the <html lang="ja"> tag, and SEO metadata.
  2. translationLocale (The Data): What we can actually show. This controls the dictionary loaded from KV.

Why this matters: If a user visits /ja/about but we haven’t translated the marketing page into Japanese yet, the system doesn’t redirect.

  • uiLocale remains "ja" (preserving the URL and user preference).
  • translationLocale gracefully falls back to "en".

The site never breaks with undefined is not a function. The user sees the interface in English, but the app structure remains stable. This is Graceful Degradation baked into the core.

Cloudflare KV Data Layer: Solving the “Fat Worker”

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.

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.

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.

I refused to ship text as code.

The Solution: KV as the Source of Truth

I moved the translation dictionaries out of the _worker.js bundle and into Cloudflare KV.

In this architecture, translations are treated strictly as external data. They are stored with keys like: edgekits:landing:en, edgekits:common:de.

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.

Edge Caching: The Cache API “Secret Sauce”

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.

To solve this, the architecture places the Cache API (caches.default) in front of KV.

When a request comes in:

  1. The Worker checks the Edge Cache for edgekits:landing:en.
  2. Hit: It serves instantly (sub-millisecond latency).
  3. Miss: It fetches from KV, constructs the response, and puts it into the Cache with a stale-while-revalidate directive.
Flowchart demonstrating Edge Cache API intercepting requests before hitting Cloudflare KV

The Economic Logic: I accept the latency cost (and the KV bill) on the 1st request to buy 0ms latency and zero KV read costs for the next 10,000 requests.

This allows the system to serve millions of users while staying comfortably within the limits of the Free tier.

The Trade-off: HTML Payload Size

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

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.

The Risk: 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).

Pro Tip: Namespace Splitting

To mitigate the payload risk, adoption of Namespace Splitting is mandatory.

Do not dump every string into a single global common.json. That is a lazy pattern inherited from the SPA era.

Instead, break your translations into granular domains:

  • buttons.json (Global UI elements)
  • landing.json (Landing page only)
  • pricing.json (Pricing page only)
  • dashboard.json (App only)

In EdgeKits, the fetchTranslations function accepts an array of namespaces. On the Landing Page, I only load ['common', 'hero']. The heavy dashboard 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.

Astro Middleware: The i18n Routing Controller

In a standard Astro app, you might be tempted to check the locale inside your .astro pages or layout files.

Don’t.

If you calculate the locale in a Layout, you have already executed too much code. You need to know the language before you render a single component.

I moved this logic entirely into src/domain/i18n/middleware/i18n.ts. This file acts as the “Air Traffic Controller” for the application. It runs on the Edge, intercepts every request, and determines the uiLocale before Astro even boots up the page rendering process.

The Detection Hierarchy

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.

Locale detection hierarchy pyramid showing URL, Cookie, Accept-Language header, and Geo-IP prioritization in Astro middleware
  1. URL (The King): If the path is /es/about, the user wants Spanish. Period. This is the primary source of truth.

  2. Cookie (The Override): If the user is at the root / (where no language is specified) but has a locale cookie, I respect that preference.

  3. Browser Header (Astro Native): If no URL prefix and no cookie exist, I leverage Astro’s built-in context.preferredLocale to handle the standard Accept-Language negotiation automatically.

  4. Geo-IP (The Safety Net): If all else fails, I use the Cloudflare request.cf.country property to make a best-guess based on location.

The Implementation

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 (you can find the full code in the repo, but here I’ll just highlight the key parts):

// src/domain/i18n/middleware/i18n.ts

import type { MiddlewareHandler } from 'astro'
import { LocaleSchema, type Locale } from '@/domain/i18n/schema'
import { DEFAULT_LOCALE } from '@/domain/i18n/constants'
import { getCookieLang, setCookieLang } from '@/domain/i18n/cookie-storage'
import { mapCountryToLocale } from '@/domain/i18n/country-to-locale-map'
import { resolveLocaleForTranslations } from '@/domain/i18n/resolve-locale'

const PUBLIC_FILE_REGEX =
  /\.(ico|png|jpg|jpeg|svg|webp|gif|css|js|map|txt|xml|json|woff2?|avif)$/i

const IGNORED_PREFIXES = [
  '/api',
  '/assets',
  '/_astro',
  '/_image',
  '/_actions',
  '/favicon',
  '/robots.txt',
]

type I18nMiddlewareContext = Parameters<MiddlewareHandler>[0]

function shouldBypassI18n(pathname: string): boolean {
  if (PUBLIC_FILE_REGEX.test(pathname)) return true
  if (IGNORED_PREFIXES.some((p) => pathname.startsWith(p))) return true
  return false
}

// We wrap responses to ensure consistent security headers across the app
function applySecurityHeaders(response: Response): Response {
  // Tip: You can apply your security headers (CSP, HSTS, X-Frame-Options, etc.) here.
  // Check the GitHub repository for the example of implementation.
  return response
}

function buildLocalizedPath(locale: Locale, rest: string[]): string {
  const suffix = rest.join('/')
  return suffix ? `/${locale}/${suffix}/` : `/${locale}/`
}

function resolveFallbackLocale(context: I18nMiddlewareContext): Locale {
  const cookieLocale = getCookieLang(context.cookies)
  if (cookieLocale) return cookieLocale

  const browserRaw = context.preferredLocale
  if (browserRaw) {
    // Currently we support 2-chars locales only.
    let parsed = LocaleSchema.safeParse(browserRaw)
    if (parsed.success) return parsed.data

    // Fallback: language part only, e.g. 'pt-br' -> 'pt'
    const short = browserRaw.split('-')[0]
    parsed = LocaleSchema.safeParse(short)
    if (parsed.success) return parsed.data
  } else {
    // Cloudflare Geo-IP Strategy
    const country = context.locals.runtime?.cf?.country
    const geoLocale = mapCountryToLocale(country)
    let parsed = LocaleSchema.safeParse(geoLocale)
    if (parsed.success) return parsed.data
  }

  return DEFAULT_LOCALE
}

export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
  const url = new URL(context.request.url)
  const pathname = url.pathname

  // 0. Skip static / system paths
  if (shouldBypassI18n(pathname)) {
    return next()
  }

  const segments = pathname.split('/').filter(Boolean)
  const firstSegment = segments[0] ?? null

  const fallbackLocale = resolveFallbackLocale(context)

  // 1. No locale in URL — redirect to fallback locale root
  if (!firstSegment) {
    const target = buildLocalizedPath(fallbackLocale, [])
    if (pathname !== target) {
      return applySecurityHeaders(context.redirect(target, 302))
    }
    return applySecurityHeaders(await next())
  }

  // 2. URL has first segment — validate as locale
  const parsed = LocaleSchema.safeParse(firstSegment)
  const urlLocale: Locale | null = parsed.success ? parsed.data : null

  if (urlLocale) {
    // Keep cookie in sync with URL locale
    setCookieLang(context.cookies, urlLocale)

    // Set locales to Astro locals for downstream usage
    context.locals.uiLocale = urlLocale
    context.locals.translationLocale = resolveLocaleForTranslations(urlLocale)

    // Normalize trailing slash and structure
    const normalized = buildLocalizedPath(urlLocale, segments.slice(1))
    if (pathname !== normalized) {
      return applySecurityHeaders(context.redirect(normalized, 302))
    }

    return applySecurityHeaders(await next())
  }

  // 3. Invalid locale in URL -> treat the whole path as content missing the locale prefix.
  const target = buildLocalizedPath(fallbackLocale, segments)

  if (pathname !== target) {
    return applySecurityHeaders(context.redirect(target, 302))
  }

  return applySecurityHeaders(await next())
}

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:

// src/domain/i18n/resolve-locale.ts

// ...

export function resolveLocaleForTranslations(locale: Locale): Locale {
  return hasTranslations(locale) ? locale : DEFAULT_LOCALE
}

The Edge Capability: Geo-IP Fallback

You might notice the mapCountryToLocale helper in the fallback logic. This is where we leverage the Edge platform.

Cloudflare exposes the visitor’s country code in every request. Here is a simple, O(1) lookup map to convert codes like DE (Germany) or BR (Brazil) into supported locales.

// src/domain/i18n/country-to-locale-map.ts

import type { Locale } from './schema.ts'

const GEO_MAP: Record<string, Locale> = {
  // --- ANGLOSPHERE (Explicitly set to EN) ---
  US: 'en', // United States
  GB: 'en', // United Kingdom
  AU: 'en', // Australia
  NZ: 'en', // New Zealand
  IE: 'en', // Ireland
  CA: 'en', // Canada (Majority EN, fallbacks handle FR better via Accept-Language headers)

  // --- DACH (German) ---
  DE: 'de',
  AT: 'de',
  CH: 'de',
  LI: 'de',

  // --- LATAM + SPAIN ---
  ES: 'es',
  MX: 'es',
  AR: 'es',
  CO: 'es',
  CL: 'es',
  PE: 'es',
  VE: 'es',
  GT: 'es',
  EC: 'es',

  // Asia
  JP: 'ja',

  // --- LUSOPHONE (Portuguese) ---
  PT: 'pt',
  BR: 'pt',
  AO: 'pt',
  MZ: 'pt',

  // ... add others as needed
}

export function mapCountryToLocale(country: unknown): string | undefined {
  if (typeof country !== 'string') return undefined
  return GEO_MAP[country.toUpperCase()]
}

This ensures that a user visiting from Tokyo gets Japanese automatically, even if their browser headers are ambiguous, but only if they haven’t explicitly chosen a different language via URL or Cookie.

Why This Design?

This middleware establishes context.locals.uiLocale as the single source of truth.

The React components don’t check localStorage. The Layout doesn’t parse the URL. They simply read uiLocale 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.

The “Dumb” Client: Type-Safe i18n for Astro Islands

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 “Islands” (usually written in React, Vue, or Svelte).

This is where performance usually dies.

When a React Island wakes up (hydrates), it often realizes: “Wait, I need text!”. The standard SPA reflex is to fire a hook like useTranslation, which triggers a network request for a JSON file, shows a loading spinner, and finally causes a Layout Shift (CLS) when the text arrives.

The “Standard” React Way (Anti-Pattern for Edge):

// ❌ Bad: Triggers network fetch + Re-render
const { t, ready } = useTranslation()
if (!ready) return <Spinner />
return <div>{t('welcome_message')}</div> // 'welcome_message' is often an untyped string

In EdgeKits, we treat the Client as “dumb”. 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.

The Mechanism: Strict Prop Drilling

We moved the complexity from the Components to the Server (the .astro file).

The page fetches the specific namespaces it needs from the Edge Cache and passes them down to the Island as a simple JSON object.

The EdgeKits Way:

// src/components/layout/Hero.tsx

// The I18n namespace is globally available via src/i18n.generated.d.ts (auto-generated by "npm run i18n:bundle")

interface HeroProps {
  // 1. Strict Type Safety: We know EXACTLY what 'hero' contains
  t: I18n.Schema['landing']['hero']
}

export function Hero({ t }: HeroProps) {
  // 2. No hooks. No generic strings. Just data.
  // 3. Renders instantly. Zero CLS.
  return <h1>{t.headline}</h1>
}

This results in Zero CLS. The HTML arrives at the browser with the text already inside the tags.

Type-Safety: “It compiles, therefore it works”

One of the biggest risks in i18n is “key drift”—when your code asks for t.description but the JSON file has t.desc.

I refused to use any.

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

  • If you delete a key in en/common.json, the build fails.
  • If you mistype a prop name, the build fails.
  • You get autocomplete for every single string in your project.

This turns internationalization from a runtime guessing game into a compile-time guarantee.

VS Code autocomplete demonstrating end-to-end type safety for i18n dictionaries in Astro

Safe Interpolation: The fmt() Helper

Raw JSON is static, but UI is dynamic. We often need to inject variables like "Hello, {name}!".

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

It handles two critical jobs:

  1. Variable Injection: Replaces {name} with values.
  2. XSS Protection: It automatically escapes injected values while preserving HTML tags defined in your translation files.

The Pattern: Keep the HTML structure in the JSON, but inject safe data.

locales/en/common.json:

{
  "welcome": "Welcome back, <strong>{name}</strong>!"
}

src/components/common/Welcome.tsx:

import { fmt } from '@/domain/i18n/format'

export function Welcome({ t, userName }) {
  // 'fmt' escapes 'userName' to prevent XSS,
  // but preserves the <strong> tag from the JSON.
  const html = fmt(t.welcome, { name: userName })

  return <span dangerouslySetInnerHTML={{ __html: html }} />
}

This gives us the flexibility of rich-text translations without the security nightmares of unescaped HTML.

Usage in Astro Components

While React forces us to use the verbose dangerouslySetInnerHTML, Astro provides a much cleaner native directive: set:html.

Because our fmt() helper has already neutralized any malicious input in the variables, passing the resulting string directly to Astro’s set:html is completely safe.

---
// Example.astro

import { fmt } from '@/domain/i18n/format'

const { common } = Astro.props

// JSON pattern: "emphasis": "Please check the <em>{content}</em> file."
const msg1 = fmt(common.ui.emphasis, { content: 'wrangler.jsonc' })

// JSON pattern: "codeSnippet": "Run <code>{code}</code> to start the server."
const msg2 = fmt(common.ui.codeSnippet, { code: 'npm run dev' })
---

<p class="font-semibold" set:html={msg1} />

<div class="bg-muted p-4 rounded-md" set:html={msg2} />

Localizing React Islands in Astro MDX (The “Final Boss”)

Using React components inside Markdown (MDX) is easy. Using internationalized components inside Markdown is a nightmare.

Why? Because MDX is static content. It doesn’t have access to the request context, cookies, or the Astro.locals object we set up in our middleware.

If you try to use a standard useTranslation hook inside an Island embedded in MDX, it will fail because the component renders in isolation.

The Wrapper Pattern: SSR Prop Injection in Astro

The Wrapper Pattern showing Astro server fetch injecting props into a pure React UI island

We solve this by treating the Astro component as a “Data Controller” and the React component as a “Pure View”.

Instead of making the React component fetch its own translations, we create a thin .astro wrapper that:

  1. Runs on the server.
  2. Accesses Astro.locals.translationLocale.
  3. Fetches the specific translation namespace from KV (or Cache).
  4. Passes the data as typed props to the React component.

1. The React Component (Pure & Dumb)

Notice that this component has zero dependencies on any third-party i18n library.

// src/components/islands/LocalizedCounter.tsx

import { useState } from 'react'

import { cn } from '@/shadcn/utils'
import { pluralIcu } from '@/domain/i18n/format'
import type { PluralPatterns } from '@/domain/i18n/format'

interface CounterLabels {
  increment: string
  reset: string
  patterns: PluralPatterns
}

interface LocalizedCounterProps {
  t: I18n.Schema['blog']['counter']
  initial?: number
  locale: string
  labels: CounterLabels
}

export const LocalizedCounter = ({
  t,
  initial = 0,
  locale,
  labels,
}: LocalizedCounterProps) => {
  const [count, setCount] = useState(initial)

  const formattedLabel = pluralIcu(count, locale, labels.patterns)
  const max = 999

  const handleIncrement = () => {
    setCount((prev) => (prev < max ? prev + 1 : prev))
  }

  const handleReset = () => {
    setCount(initial)
  }

  return (
    <div className="bg-card flex flex-col items-start gap-4 rounded-xl border p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between sm:p-5">
      <div className="space-y-5">
        <div className="text-muted-foreground text-xs font-semibold tracking-widest uppercase">
          {t.title}
        </div>

        <div className="flex w-24 flex-col items-center gap-2">
          <div className="bg-input w-full rounded-md text-center">
            <span
              className={cn(
                'text-4xl font-semibold tabular-nums',
                count === max && 'text-primary',
              )}
            >
              {count}
            </span>
          </div>

          <div className="text-muted-foreground text-sm">{formattedLabel}</div>
        </div>
      </div>

      <div className="mt-2 flex gap-2">
        <button
          type="button"
          onClick={handleIncrement}
          disabled={count === max}
          className={cn(
            'text-primary-foreground inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-sm font-semibold shadow-sm transition',
            count === max
              ? 'bg-primary/50 cursor-not-allowed'
              : 'bg-primary hover:bg-primary/90',
          )}
        >
          {labels.increment}
        </button>

        <button
          type="button"
          onClick={handleReset}
          className="text-muted-foreground hover:bg-muted inline-flex items-center justify-center rounded-lg border px-3 py-1.5 text-sm font-medium transition"
        >
          {labels.reset}
        </button>
      </div>
    </div>
  )
}

2. The Astro Wrapper (The Bridge)

This file lives in your components folder but acts as the glue between your MDX content and your Edge data.

---
// src/components/blog/LocalizedCounterWrapper.astro

import { LocalizedCounter } from '@/components/islands/LocalizedCounter'
import { fetchTranslations } from '@/domain/i18n/fetcher'

const { translationLocale, runtime } = Astro.locals
const { blog } = await fetchTranslations(runtime, translationLocale, ['blog'])
const t = blog.counter
---

<LocalizedCounter
  client:visible
  t={t}
  locale={translationLocale}
  labels={blog.counter}
/>

3. Usage in MDX

Now we simply inject our island component wrapper into the components prop in /pages/[lang]/blog/[...slug].astro

---
// pages/[lang]/blog/[...slug].astro

import { getEntry } from 'astro:content'

import { DEFAULT_LOCALE } from '@/domain/i18n/constants'
import { fetchTranslations } from '@/domain/i18n/fetcher'
import BaseLayout from '@/layouts/BaseLayout.astro'
import LocalizedCounterWrapper from '@/components/blog/LocalizedCounterWrapper.astro'

// 1. Get parameters from URL
const { lang, slug } = Astro.params

if (!slug) {
  // No slug? We return 404 status without changing the URL.
  return Astro.rewrite(`/${lang}/404/`)
}

// 2. Take uiLocale and translationLocale from locals
// uiLocale - the language the user requested (from the URL or cookies)
// translationLocale - a language for which there are dictionaries in KV
const { uiLocale, translationLocale, runtime } = Astro.locals

// 3. Graceful Fallback Logic for Content
// Step A: Try to find an article in the requested language
let post = await getEntry('blog', `${uiLocale}/${slug}`)

// Step B: If there is no translation, roll back to the default locale (English)
if (!post) {
  post = await getEntry('blog', `${DEFAULT_LOCALE}/${slug}`)

  // We can also trigger the MissingTranslationBanner here
  // (see the explanation below in the Graceful Degradation & The “Honest UX” chapter)
  Astro.locals.isMissingContent = true
}

// Step C: If the article isn't even in English, it's a fair 404.
if (!post) {
  // Turns off the MissingTranslationBanner if it was triggered above
  Astro.locals.isMissingContent = false
  // We return 404 status without changing the URL.
  return Astro.rewrite(`/${uiLocale}/404/`)
}

// 4. Render Content
const { Content, headings } = await post.render()

// 5. Fetch UI Translations (KV)
// We load dictionaries via translationLocale. Even if the article text is in English,
// the entire blog layout will remain localized!
const { blog } = await fetchTranslations(runtime, translationLocale, ['blog'])
---

<BaseLayout title={post.data.title} description={post.data.metaDescription}>
  <article class="max-w-3xl mx-auto mb-12 pt-6 pb-12 px-6 bg-background">
    <header class="mb-8 flex flex-col items-start">
      <h1 class="text-3xl font-bold mb-4 text-primary">
        {post.data.title}
      </h1>
    </header>

    {/* Markdown/MDX Content Body */}
    <div class="prose">
      <Content
        components={{
          // Map the MDX tag to the Astro Wrapper, not the React component
          LocalizedCounter: LocalizedCounterWrapper,
        }}
      />
    </div>
  </article>
</BaseLayout>

And then we can use it directly in MDX:

//interactive-mdx-demo-with-edge-i18n.mdx

---

## title: 'Interactive i18n Demo'

This is a standard paragraph in Markdown. The text you are reading now lives in the filesystem.

## The "Hybrid" Component

Below is a React Island. Its structure is defined in code, but **the text labels come from Cloudflare KV**.

<LocalizedCounter />

This allows content creators to use complex UI widgets without worrying about localization files.

Live Demo

Here is a live proof of concept.

The form below isn’t a screenshot. It is a fully hydrated Interactive Island running inside this static MDX page. It uses the exact Wrapper Pattern described above to inject localized labels and handle server actions without layout shift.

Go ahead, try it out. (If you haven’t joined the list yet, this will actually subscribe you).

Leave your email to get launch discount • No spam ever

Why This Wins

This architecture allows us to have rich, interactive, localized widgets embedded deep within static content (like documentation or blog posts) without shipping a single byte of translation JSON to the client for the rest of the page. The specific strings needed for the counter are “baked” into the component props during the server render.

Resilience & Tooling: Production Grade

Building a system that works on localhost is easy. Building a system that survives a partial cloud outage or a bad deployment is hard.

Since we removed the client-side fetch, the Server (Worker) becomes the single point of failure. If Cloudflare KV is slow or returns an error, we cannot show a blank page.

The Safety Net: Compiled Fallbacks

We implemented a “Belt and Suspenders” approach.

  1. The Belt (Cloudflare KV): Stores all translations for all languages. It is dynamic and can be updated instantly.
  2. The Suspenders (Compiled Fallbacks): We compile the Default Locale (e.g., English) directly into the Worker bundle as a JavaScript object.

How it works: When the middleware requests translations, it performs a deepMerge operation:

Deep merge fallback strategy ensuring valid UI even if KV store is disconnected
// Logic inside fetchTranslations()
const kvResult = await fetchFromKV(namespace) // Might fail or be partial
const fallback = FALLBACK_DICTIONARIES[namespace] // Always exists in memory

// If KV fails, we still render the page in English (Default Locale).
// If KV is partial, we fill in the missing keys from the fallback.
const finalData = deepMerge(fallback, kvResult)

This guarantees 100% Uptime for your base language. Even if the KV database goes offline completely, your site will still render perfectly in English. No white screens, no “undefined” labels.

Solving Cache Invalidation (The “Hard” Problem)

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.

We solved this with Content-Based Hashing.

Every time you run the build script (npm run i18n:bundle), we calculate a SHA-hash of your translation files. This hash is injected into the code as a constant: TRANSLATIONS_VERSION.

The Cache Key structure looks like this: project_id:i18n:v<HASH>::<locale>:<namespace>

  • Scenario A (No changes): You redeploy the code, but didn’t touch locales. The Hash stays the same. The Cache HIT rate remains 100%.
  • Scenario B (Typo fix): You change a string in common.json. The Hash changes. The Worker immediately starts using a new Cache Key.

The result? Instant updates for users, with zero manual cache purging required.

The Developer Experience (DX)

Working with Edge KV stores can be tedious. I didn’t want to manually use wrangler kv:key put for every single JSON file.

We automated the entire workflow with three scripts:

  1. npm run i18n:bundle: Scans src/locales, generates the TypeScript Schema, calculates the Version Hash, and prepares a single JSON payload.
  2. npm run i18n:seed: Uploads this payload to your Local KV (Miniflare) so npm run dev works offline.
  3. npm run i18n:migrate: Uploads the payload to your Production Cloudflare KV.

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.

i18n URL Strategy: Why We Don’t Translate Slugs

When building a multilingual site, the instinct is often to translate everything, including the URL path.

  • English: /blog/architecture
  • German: /de/blog/architektur
  • Ukrainian: /uk/blog/архітектура

In EdgeKits, I deliberately chose not to do this. We use English Slugs across all locales.

The Problem with Localized Slugs

At first glance, localized URLs seem better for SEO. In reality, they introduce significant technical debt and UX issues, especially for non-Latin alphabets.

1. The “Percent-Encoding” Nightmare

Browsers and messengers encode non-ASCII characters. A clean Ukrainian URL turns into an unreadable mess when you copy-paste it into Slack, WhatsApp, or Jira:

/uk/blog/%D0%B0%D1%80%D1%85%D1%96%D1%82%D0%B5%D0%BA%D1%82%D1%83%D1%80%D0%B0

This looks suspicious to users (like spam/phishing) and breaks the clean aesthetic of the link.

2. The Git/Filesystem Conflict

Different operating systems (macOS vs. Windows/Linux) handle Unicode normalization differently (NFC vs. NFD).

Having filenames like архітектура.md in your git repository is a recipe for cross-platform merge conflicts and “file not found” errors in CI/CD pipelines.

3. Architectural Complexity

If you translate slugs, you need a “Lookup Table” (or a massive switch statement) to know that architektur corresponds to architecture.

This forces you to load a map of every single blog post into memory just to resolve a route or generate language links.

The EdgeKits Approach: Canonical English Slugs

We keep the structure identical across languages. The locale prefix changes, but the slug remains the canonical identifier.

  • /en/blog/architecture
  • /de/blog/architecture
  • /uk/blog/architecture

Why this wins:

  1. Stable Sharing: The URL is clean, readable, and short in any chat app, regardless of the user’s language.

  2. Simple Code: We don’t need reverse-lookup maps. To fetch the Ukrainian post, we just look for src/content/blog/uk/architecture.mdx. The file system is the source of truth.

  3. Automated SEO (Hreflangs): Because the slug never changes, generating hreflang tags becomes a simple string replacement operation ($O(n)$ complexity).

The Implementation

Here is the helper component that generates the tags automatically:

---
// src/domain/seo/components/SeoHreflangs.astro

const { currentLang } = Astro.props
const currentPath = Astro.url.pathname

// Since slugs are identical, we just swap the prefix
// Input: /en/blog/architecture, Target: uk -> Output: /uk/blog/architecture
function getPathForLang(targetLang) {
  return currentPath.replace(`/${currentLang}/`, `/${targetLang}/`)
}
---

{
  Object.keys(LANGUAGES).map((lang) => (
    <link
      rel="alternate"
      hreflang={lang}
      href={new URL(getPathForLang(lang), Astro.site).toString()}
    />
  ))
}

And here is how we drop it into the global layout. You write this once, and every page on your site instantly has perfect SEO linkage:

---
// src/layouts/BaseLayout.astro

import SeoHreflangs from '@/domain/seo/components/SeoHreflangs.astro'

const { uiLocale } = Astro.locals // Provided by Middleware
---

<!doctype html>
<html lang={uiLocale}>
  <head>
    <link rel="canonical" href={Astro.url} />

    <SeoHreflangs currentLang={uiLocale} />
  </head>
  <body>
    <slot />
  </body>
</html>

By accepting this trade-off, we eliminate an entire class of bugs related to encoding and routing, while ensuring Google always knows which version of the page to serve.

Graceful Degradation & The “Honest UX”

By keeping the English slugs canonical, we solved the routing problem. But what happens at the file-system level?

If a user visits /es/blog/architecture, Astro will look for src/content/blog/es/architecture.mdx. 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 .mdx file into the /es/ folder just to prevent the crash. That is a maintenance nightmare.

Because we decoupled the user’s intent (uiLocale) from the available data, we can handle this gracefully at the data-fetching layer. Inside our dynamic route ([...slug].astro), we implemented a dual-fetch fallback:

---
// 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/`)
}

// ...
---

The result is pure magic for the User Experience: The user visits /es/blog/architecture. The article text renders in English, but because uiLocale is still "es", the entire surrounding interface — the navigation menu, the footer, and the formatted Publish Date — remains perfectly localized in Spanish. No 404s. No duplicated files.

The Missing Translation Banner (Dual-Mode)

However, silently swapping content languages can confuse users. To solve this, I introduced the “Honest UX” pattern via a MissingTranslationBanner component.

Instead of a generic warning, the system differentiates between two distinct failure modes: Missing Content (Markdown) and Missing UI (JSON dictionaries).

  1. Content is missing: If Astro.locals.isMissingContent was flagged by our router, the banner tells the user specifically about the text: “Sorry, this article is not yet available in your selected language.”

  2. UI is missing: What if the Markdown content exists, but a translator forgot to add blog.json to the Spanish directory? During the build phase (npm run i18n:bundle), our script statically analyzes the filesystem and generates an array of FULLY_TRANSLATED_LOCALES. If the current locale isn’t in that list, the banner warns: “Sorry, this page is not yet fully available in your selected language.”

Because this banner is isolated, it reads the context directly from Astro.locals and fetches its own localized strings from the messages namespace. I also added a final layer of armor: explicit hardcoded fallbacks right inside the component, just in case the messages.json dictionary itself is the one missing.

---
// 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.'
}
---

And the function that triggers the banner:

// src/domain/i18n/resolve-locale.ts

// ...

// Checking the completeness of translations
function isFullyTranslated(locale: Locale): boolean {
  return (FULLY_TRANSLATED_LOCALES as readonly string[]).includes(locale)
}

type MissingTranslationType = 'ui' | 'content' | null

export function checkMissingTranslation(
  uiLocale: Locale,
  isMissingContent: boolean | undefined,
): MissingTranslationType {
  if (!ENABLE_MISSING_TRANSLATION_BANNER) return null

  if (isFullyTranslated(uiLocale) && !isMissingContent) return null

  return isMissingContent ? 'content' : 'ui'
}

This is a robust, Zero-JS fallback mechanism that prioritizes transparency and stability above all else. Even if your localization files are completely fragmented, the app structure holds together, and the user is always informed.


Conclusion: This Is Just the Beginning

We started this journey with a heavy, client-side approach that forced the user’s browser to do the heavy lifting. We ended up with an architecture that is:

  1. Fast: Zero client-side JS for translations. 0ms CLS.

  2. Safe: Fully typed via generated TypeScript schemas.

  3. Resilient: Protected by Edge Caching and compiled Fallbacks.

  4. Clean: No “prop-drilling” hell, thanks to Middleware and Astro Islands injection.

But a real-world application is more than just static pages and blog posts.

What about the dynamic stuff?

You might be asking: “Okay, but what happens when I submit a form via Astro Actions? How do I translate the server error?” “How do I localize Zod validation errors inside React Hook Form without shipping a massive dictionary?” “How do I handle system messages returned from bound workers via wrangler.jsonc?”

These are complex, “Level 2” challenges that require their own deep dive.

Coming Soon: Part 2

I am planning to write the second part of this series soon, where we will tackle the Interactive Layer:

  • API & Actions: Passing localized context to server-side procedures.

  • Zod & React Hook Form: A pattern for “Lazy Validation Messages” that keeps the bundle small.

  • System Errors: Handling output from bound workers (Service Bindings).

  • Dynamic Content & Cloudflare D1: How to design database schemas and write SQL queries for User-Generated Content using the “Split Brain” locale strategy.

If you want to be the first to read it, follow me on X @GaryEdgeKits.


Get the Code

You don’t have to build this from scratch. The entire architecture discussed today is available as an open-source starter kit.

Because the “Split Brain” architecture fundamentally changes how URLs and locales interact, standard Astro plugins for SEO won’t work out of the box. To save you time, the repository already includes custom, Edge-native implementations for:

  • 🗺️ Dynamic sitemap.xml: Automatically maps canonical slugs to all translated locales.
  • 📡 Multilingual rss.xml: Feeds that respect your fallback logic.
  • 🤖 llms.txt: Ready for the AI-search era.

👉 Star the Repo & Start Building: https://github.com/EdgeKits/astro-edgekits-core.

February 21, 2026
← Back to Overview