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.
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 (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.
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 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.
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:
- Interception: The request hits the Cloudflare Worker.
- Resolution: Our Middleware analyzes the request immediately - checking URL paths (
/de/), cookies, and headers. - Data Fetch: The Worker checks the Edge Cache. If it's a miss, it fetches from KV and hydrates the cache.
- Injection: Translations are injected directly into Astro props.
- Rendering: Astro generates HTML with strings baked in.
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:
-
uiLocale(The Intent): What the user wants to see. This controls the URL (/ja/about), the<html lang="ja">tag, and SEO metadata. -
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.
-
uiLocaleremains"ja"(preserving the URL and user preference). -
translationLocalegracefully 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:
- The Worker checks the Edge Cache for
edgekits:landing:en. - Hit: It serves instantly (sub-millisecond latency).
- Miss: It fetches from KV, constructs the response, and puts it into the Cache with a
stale-while-revalidatedirective.
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.
- URL (The King): If the path is
/es/about, the user wants Spanish. Period. This is the primary source of truth. - Cookie (The Override): If the user is at the root
/(where no language is specified) but has alocalecookie, I respect that preference. - Browser Header (Astro Native): If no URL prefix and no cookie exist, I leverage Astro's built-in
context.preferredLocaleto handle the standardAccept-Languagenegotiation automatically. - Geo-IP (The Safety Net): If all else fails, I use the Cloudflare
request.cf.countryproperty 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:
// 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']
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
}
function applySecurityHeaders(response: Response): Response {
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) {
let parsed = LocaleSchema.safeParse(browserRaw)
if (parsed.success) return parsed.data
const short = browserRaw.split('-')[0]
parsed = LocaleSchema.safeParse(short)
if (parsed.success) return parsed.data
} else {
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
const segments = pathname.split('/').filter(Boolean)
const firstSegment = segments[0] ?? null
let safeLocale = DEFAULT_LOCALE
if (firstSegment) {
const parsed = LocaleSchema.safeParse(firstSegment)
if (parsed.success) {
safeLocale = parsed.data
}
}
context.locals.uiLocale = safeLocale
context.locals.translationLocale = resolveLocaleForTranslations(safeLocale)
if (shouldBypassI18n(pathname)) {
const response = await next()
const contentType = response.headers.get('content-type') || ''
const isHtml = contentType.includes('text/html')
const isRedirect = response.status >= 300 && response.status < 400
if (isHtml && !isRedirect) {
return new Response('Not found', {
status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
}
return response
}
const fallbackLocale = resolveFallbackLocale(context)
if (!firstSegment) {
const target = buildLocalizedPath(fallbackLocale, [])
if (pathname !== target) {
return applySecurityHeaders(context.redirect(target, 302))
}
return applySecurityHeaders(await next())
}
const parsed = LocaleSchema.safeParse(firstSegment)
const urlLocale: Locale | null = parsed.success ? parsed.data : null
if (urlLocale) {
setCookieLang(context.cookies, urlLocale)
context.locals.uiLocale = urlLocale
context.locals.translationLocale = resolveLocaleForTranslations(urlLocale)
const normalized = buildLocalizedPath(urlLocale, segments.slice(1))
if (pathname !== normalized) {
return applySecurityHeaders(context.redirect(normalized, 302))
}
return applySecurityHeaders(await next())
}
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 ---
US: 'en',
GB: 'en',
// --- DACH ---
DE: 'de',
// --- LATAM + SPAIN ---
ES: 'es',
// Asia
JP: 'ja',
}
export function mapCountryToLocale(country: unknown): string | undefined {
if (typeof country !== 'string') return undefined
return GEO_MAP[country.toUpperCase()]
}
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".
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).
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>
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 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
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.
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().
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 }} />
}
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 because MDX doesn't have access to Astro.locals.
The Wrapper Pattern: SSR Prop Injection in Astro
We solve this by treating the Astro component as a "Data Controller" and the React component as a "Pure View".
1. The React Component (Pure & Dumb)
// src/components/blog/islands/LocalizedCounter.tsx
import { useState } from 'react'
import { pluralIcu } from '@/domain/i18n/format'
interface LocalizedCounterProps {
t: I18n.Schema['counter']
initial?: number
locale: string
}
export const LocalizedCounter = ({ t, initial = 0, locale }: LocalizedCounterProps) => {
const [count, setCount] = useState(initial)
const formattedLabel = pluralIcu(count, locale, t.patterns)
return (
<div>
<span>{count}</span>
<span>{formattedLabel}</span>
<button onClick={() => setCount(count + 1)}>{t.increment}</button>
</div>
)
}
2. The Astro Wrapper (The Bridge)
---
// 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
---
<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 our dynamic route, and use <LocalizedCounter /> directly in our .mdx files. The specific strings needed for the counter are "baked" into the component props during the server render.
Resilience & Tooling: Production Grade
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.
- The Belt (Cloudflare KV): Stores all translations. Dynamic.
- The Suspenders (Compiled Fallbacks): We compile the Default Locale (e.g., English) directly into the Worker bundle as a JavaScript object.
// 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.
const finalData = deepMerge(fallback, kvResult)
This guarantees 100% Uptime for your base language.
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:
-
npm run i18n:bundle: Scanssrc/locales, generates the TypeScript Schema, calculates the Version Hash, and prepares a single JSON payload. -
npm run i18n:seed: Uploads this payload to your Local KV (Miniflare) sonpm run devworks offline. -
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 (/de/blog/architektur).
In EdgeKits, I deliberately chose not to do this. We use English Slugs across all locales (/de/blog/architecture).
Why this wins:
- Stable Sharing: The URL is clean and short in any chat app, avoiding Percent-Encoding nightmares for non-Latin alphabets.
- Simple Code: We don't need reverse-lookup maps. The file system is the source of truth.
- Automated SEO: Generating
hreflangtags becomes a simple string replacement operation.
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 article text renders in English, but 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).
Content is missing: If
Astro.locals.isMissingContentwas flagged by our router, the banner tells the user specifically about the text: "Sorry, this article is not yet available in your selected language."UI is missing: What if the Markdown content exists, but a translator forgot to add
blog.jsonto the Spanish directory? During the build phase (npm run i18n:bundle), our script statically analyzes the filesystem and generates an array ofFULLY_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.
Conclusion: This Is Just the Beginning
We started this journey with a heavy, client-side approach and ended up with an architecture that is:
- Fast: Zero client-side JS for translations. 0ms CLS.
- Safe: Fully typed via generated TypeScript schemas.
- Resilient: Protected by Edge Caching and compiled Fallbacks.
- Clean: No "prop-drilling" hell, thanks to Middleware.
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.
- Dynamic Content & Cloudflare D1: 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.
👉 Star the Repo & Start Building: https://github.com/EdgeKits/astro-edgekits-core









Top comments (0)