Jan Aushadhi Dost: a 2,439-medicine search that runs in your browser
Client-side search over India's public generic-medicine catalogue, an AI reference that is forbidden from knowing doses, and the two bugs that taught us the most.
We shipped Jan Aushadhi Dost, a mobile-first search over India's public Jan Aushadhi medicine catalogue. The engineering notes: a fuzzy-search bug that returned an antidepressant for ORS, a no-dose linter for AI content, and a Turnstile widget that died on navigation.
We shipped Jan Aushadhi Dost, a mobile-first search over the Government of India's public Jan Aushadhi generic-medicine catalogue. 2,439 medicines, instant search, price-watch signup, savings lists, and an AI reference that is structurally forbidden from stating a dose. Infrastructure cost: about zero rupees.
The catalogue itself is public data from PMBI, the government body behind Jan Aushadhi stores. The official portal is hard to search on a phone, and that is the whole gap: the same medicine often costs a fraction of the branded price, but you have to be able to find it. So the site is an Astro static build served by a Cloudflare Worker, with D1 for contact requests and KV for rate limits. Search never touches a server: the catalogue ships as JSON and MiniSearch indexes it in the browser in a few milliseconds.
The search bug that returned an antidepressant for ORS
During a review pass, the owner searched ORS, the oral rehydration salts every Indian parent knows. The results: two antidepressants. That is about the worst failure a medicine search can have, so we dug in.
Query: "ORS"
Results:
Dosulepin (or Dothiepin) Tablets 25 mg <- antidepressant
Dosulepine (or Dothiepin) Tablets IP 75mg <- antidepressant
Expected:
Oral Rehydration Salts IP (WHO Formula)The chain: the catalogue spells the product name as "Dosulepin (or Dothiepin)", so the tokenizer indexed the word "or". Our MiniSearch config had fuzzy matching at 0.2, which for a three-letter query allows one edit. "ors" is one deletion away from "or". A connective word inside a parenthetical became fuzzy-match bait for a completely unrelated query.
The fix has three layers, because each one covers a different failure. Stopwords (or, and, of, the, with) are never indexed. Queries of three characters or fewer get no fuzzy matching at all, since at that length one edit reaches different words, not typos of the same word. And a whole-word alias expands "ors" to "oral rehydration salts" so the shorthand actually lands on the product.
const STOPWORDS = new Set(["or", "and", "of", "the", "with"]);
const QUERY_ALIASES: Record<string, string> = {
ors: "oral rehydration salts",
};
// in the MiniSearch options
processTerm: (term) => {
const lower = term.toLowerCase();
return STOPWORDS.has(lower) ? null : lower;
},
searchOptions: {
prefix: true,
fuzzy: (term) => (term.length > 3 ? 0.2 : false),
},A smoke test now asserts that "ors" returns rehydration salts and never the antidepressants. If fuzzy search sits anywhere near safety-relevant data, length-gate it. One edit on a short token is a different word.
An AI reference that is forbidden from knowing doses
The owner wanted each medicine page to say what the medicine is for, who typically takes it, and whether it goes before or after food. Useful, and also the kind of content where a wrong number hurts someone. The project rule we settled on is blunt: the AI reference never states a dose, a frequency, or a duration. For the correct dose and duration, the site tells readers to connect with the relevant medical authority or their doctor, every time.
Rules in a prompt are wishes. So the rule is enforced by a linter that scans every generated entry for dose-like text: unit patterns like 500mg or 10ml, and schedule patterns like twice a day or for 5 days. Anything the catalogue row itself does not contain (pack strength is fine, it is printed on the box) gets the entry rejected and queued for regeneration.
Coverage is layered: 66 high-traffic medicines have hand-reviewed per-molecule entries under three regulatory contexts (India, Europe, USA), and the remaining 2,373 fall back to reviewed drug-group content. Every entry is labelled AI-generated with the model, date, and a contact for corrections, and the linter runs against all of it on every test run. The batch pipeline for per-molecule coverage of the full catalogue exists and waits on human review, which we think is the only honest order of operations for medical content.
The Turnstile widget that died on navigation
The price-watch form uses Cloudflare Turnstile. Turnstile's default mode scans the DOM once, when its script loads. Astro's view transitions swap the page body without reloading scripts. Navigate Home, then to another page, then back to Home, and the form's widget container is brand new DOM that nobody ever scanned. The form looked fine and could never submit.
// api.js?render=explicit, then on every astro:page-load:
turnstileWidgetId = window.turnstile.render(container, {
sitekey,
action: "subscribe",
});
// and after submit, reset by id, not by default lookup:
window.turnstile.reset(turnstileWidgetId);Explicit rendering on every page-load event, a data attribute guarding double renders, and a stored widget id for the reset. If you combine any SPA-style router with a widget that self-initializes on script load, assume the widget is wrong until you have re-rendered it yourself.
Production pushes go through a fingerprint
Deploys run through a guarded action in a separate repo. It hashes all 148 approved source files, refuses to build if the tree does not match the approved sha256, fetches the production Turnstile site key from the Cloudflare API at build time so no key lives on the machine, requires a break-glass environment variable plus a token file to mutate anything, and curls both production domains afterwards to verify the release actually serves. Slow ceremony for a small site, and worth it: the day a deploy verification failed, it was a CDN propagation race, and the fingerprint meant we knew exactly what had shipped while we checked.
What it runs on
- Astro static build, served by a Cloudflare Worker with static assets
- MiniSearch in the browser, no search backend at all
- D1 for manual price-watch contact requests, KV for rate limits
- Turnstile for bot protection, explicit render mode
- Monthly infra bill: roughly zero, everything sits in free tiers
Next step: try it at india-aushadi.gattyworks.com, searching for a medicine someone in your family takes is the fastest way to feel the price gap. It sits with the rest of our shipped work on the tools page. If your team wants a public-data product built like this, catalogue to live site, write to hello@gattyworks.com.