Adinath is not a portfolio piece. It's the website for my family's hospital — Adinath Multispeciality Hospital in Shahibaug, Ahmedabad, which has been treating orthopedic and gynecology patients since 1990. Real doctors, real patients, real WhatsApp number on the contact page. That changes how you build. There's no "ship the demo and iterate" comfort when the people booking an appointment are someone's parents in Gujarat trying to find the clinic in their own language.
This is the honest version of how it went. The site works — Astro, static output, deployed to AWS Amplify, 38 pages. But two of the early architectural bets were wrong, and the interesting part is why they were wrong and what it took to see it. This is a post-mortem on those two, not a victory lap.
The two prices of "it works"
The site shipped and looked done. Both bugs were the kind that don't show up until a real user does something reasonable.
- Bet one: runtime i18n. Translate the page in the browser by swapping text after load. Felt pragmatic. It was a trap.
- Bet two: React for everything interactive. Reach for an island whenever a page needs to do something. Felt like the modern default. It was ~360KB of default.
Both came from the same instinct — solve the local problem with the tool already in my hand — and both ignored that Astro's whole pitch is to push work to build time. I was fighting the framework with the framework.
Bet one: the split-brain translation system
The hospital serves three languages: English, Hindi, Gujarati. That's not a nice-to-have in Ahmedabad; it's the difference between a patient understanding the page and bouncing. So i18n was load-bearing from day one.
The problem is I built it twice, in two different mechanisms, that didn't know about each other.
user clicks "ગુજરાતી"
│
┌─────────────────┴─────────────────┐
▼ ▼
[ static Astro HTML ] [ React islands ]
data-i18n attributes useI18n() hook, t('key')
runtime JS swaps text useState PER island
state in localStorage state isolated, no listener
▲ ▲
└──── nav LangSwitcher fires ─────────┘
a 'lang-change' CustomEvent
→ static layer hears it
→ React islands DON'T
Static pages used data-i18n DOM attributes and a runtime script (i18n-static.ts) that walked the DOM and replaced text, persisting the choice in localStorage. The React islands used a useI18n() hook with t('key') calls in JSX — and each island held its own useState. The nav's language switcher was itself a React island that dispatched a lang-change event. The static layer listened. The islands didn't.
So here's the bug a real user hits: click Gujarati, the navigation translates, and the booking form right below it stays in English. Half the page switches, half doesn't. Worse than fully-English, because it looks broken and untrustworthy on exactly the page where trust matters — the one where you hand over your name and phone number to a hospital.
The root cause wasn't a missing event listener (though that was the proximate one). It was that I had two sources of truth for the current language and no plan for them to agree. A data-i18n DOM-mutation system and a React hook system are not two implementations of i18n — they're two different applications sharing a page.
The fix: stop translating at runtime, translate at build time
Astro already solves this. It supports i18n through dynamic route segments — so instead of one page that re-translates itself in the browser, you generate one baked page per language at build time.
src/pages/
[lang]/
index.astro → /en/, /hi/, /gu/
book.astro → /en/book/, /hi/book/, /gu/book/
services/
orthopedic.astro → /en/services/orthopedic/, /hi/..., /gu/...
Each page reads lang as a param and looks up its strings at build time, so the translated text is in the HTML before the browser ever sees it:
---
const { lang } = Astro.params;
const t = getTranslations(lang);
---
<h1>{t.hero_title}</h1> <!-- baked into HTML, not patched on load -->
What this buys, all of it falling out of one decision:
- Zero runtime JS for translation on static pages — no
data-i18n, noi18n-static.ts, no flash of the wrong language on load. <html lang="gu">is correct at build time, not patched client-side.- Each language gets its own URL, so Google indexes all three — real SEO instead of one URL that mutates in place.
- The language switcher becomes a plain
<a href="/gu/book">ગુ</a>. A link. No island, no CustomEvent, no localStorage race. - React islands that remain get
langas a prop from the Astro page —<BookingWizard lang={lang} client:load />— one source of truth, nothing to sync.
The cost is 38 pages × 3 languages = 114 HTML files. For a static build that's a rounding error. The expensive thing was never the files; it was the two state machines.
There's a five-line quick win that papers over the symptom — add a
useEffectinuseI18n()that listens forlang-changeand re-renders. It unblocks language switching the same day. But it keeps both systems alive. The real fix deletes one of them.[VERIFY: Pratik — confirm how much of the [lang]-route migration has actually landed vs. the quick-win patch. The DELTA sprint migrated the legal pages to [...lang]; unclear if the full site is converted.]
Bet two: 360KB to do a single INSERT
The second bet is more embarrassing because it's pure reflex. Nine pages — booking, patient signup, status check, five onboarding flows — were built as React islands. Most of them are forms that do one Supabase insert. A name, a phone number, a preferred date, submit.
Here's what each of those pages was shipping to do that:
| Payload | Raw | Gzip | |---|---|---| | React runtime | 170KB | 58KB | | Supabase client | 182KB | 46KB | | The component itself | 8–16KB | — | | Total | ~360KB | — |
About 360KB of JavaScript so a user can type their name and have it land in a database. The form doesn't need a component tree, a hook, a render cycle, or a full client SDK. It needs an HTML <form> and a fetch().
The fix: HTML form + a 3KB script hitting the REST API
output: 'static' means no Astro server actions — but a single insert doesn't need one. Supabase exposes a REST endpoint. The whole interaction is a server-rendered (and now build-time-translated) form plus a small submit handler:
<form id="booking-form">
<input name="patient_name" placeholder={t.book_full_name} required />
<!-- ... -->
</form>
<script>
document.getElementById('booking-form').addEventListener('submit', async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
await fetch(`${SUPABASE_URL}/rest/v1/appointments`, {
method: 'POST',
headers: { 'apikey': SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
});
</script>
Roughly 3KB instead of 360KB, per page, across nine pages. [VERIFY: Pratik — the analysis estimates ~3.1MB of JS eliminated total. Confirm before publishing as a number; treat as order-of-magnitude.]
The point isn't that React is bad. The point is that React earns its weight on pages that are genuinely applications — the login flow with its multi-step auth, the role-based portals, the doctor's live patient queue, the admin CRUD tables. Those keep React and the full client, correctly. The mistake was using the heavyweight tool for the lightweight job because it was the tool already open on my desk. The architecture should match the page, not the developer's muscle memory.
The pattern under both bets
Strip away the specifics and both mistakes are the same mistake: doing at runtime, per-user, what could be done once at build time. Translating in the browser instead of baking the language in. Hydrating a component to collect three fields instead of posting a form. Astro's entire value proposition is that the default should be zero JS, and you opt into interactivity only where it pays for itself. I had it backwards — interactive by default, static as the exception — and the framework quietly let me, then sent the bill to the slowest phone on the worst connection in Ahmedabad.
A note on process, since it's part of the honest record: the architecture review and parts of this migration ran as multi-agent sprints — separate CLI agents working parallel lenses on the same codebase (one on structure, one on technical accuracy, one on positioning), with the findings compiled and committed together. It's a working pattern for getting several disciplined passes over a small, real deliverable. That's a separate post.
The site is live and treating its three languages and its real users better than the first version did. But I want the record to show the first version had two confident, wrong bets in it — and that catching them was worth more than shipping fast ever was.
I'm a trading-systems engineer — I spend most of my time on low-latency, correctness-critical infrastructure where the cost of a wrong default is measured in milliseconds and money. Adinath is the opposite end: a static site for a family hospital, where the cost of a wrong default is a patient who can't read the booking form in their own language. Same lesson in both worlds — the default you don't think about is the one that bills you later. This is a mechanism-level account of two I got wrong. Corrections welcome.