Why: a migraine, 7 PM, and two pharmacies without the medicine
My partner came home with a terrible migraine. It was seven in the evening and most pharmacies were already closed.
"No problem," I thought. "I'll look up the on-duty pharmacy and done."
Naive.
The official MINSAL page simply wouldn't load. The alternatives I found were so buried in ads it was hard to tell where you were supposed to click. When I finally got a clean address out of it, I drove to two on-duty pharmacies in my comuna and neither had the medicine. So: start the search over, this time to go to a different comuna, from my phone, out on the street, while she waited at home riding out the migraine.
That night, once she was resting, I decided this couldn't keep happening.
What annoyed me most is that the data exists: in Chile every comuna has a rotating on-duty pharmacy, and MINSAL publishes the list every day, publicly and for free. The information was never the problem. Getting to it was.
Which is a shame, because there is exactly one question here and it never changes:
Which pharmacy is on duty near me, right now, and how do I get there?
Nobody wants to explore a map with someone waiting at home. Nobody wants to filter by region, then by comuna, then accept cookies, then dodge three banners. They want a name, an address, and a button that opens the route in Google Maps. That's the action that matters: nobody stays and reads the site, they drive away. Public data served badly is, in practice, data that doesn't exist — and here the cost of not finding it is paid by someone who feels awful at a bad hour.
That detail of having to search again for a different comuna ended up becoming an architecture decision months later. We'll get there.
Within a few days I had the first version up: a simple page with the on-duty pharmacies in your comuna, no pop-ups and no 47 banners before you see the information. The same official MINSAL data, in an interface that actually works. The business plan was just as modest: eventually, some advertising to pay for the domain without becoming invasive.
That's how mifarmaciadeturno.cl started. This post is the full walkthrough: how I built it, what broke, the real numbers, and where it stands today.
The stack, in one line
Next.js 16 (App Router) + React 19 + Vercel KV + ISR, deployed on Vercel with a daily cron. No database, no separate backend, no auth. The site stores nothing about whoever visits it.
Step 1: getting the data (and fighting Cloudflare)
MINSAL exposes a public endpoint with the day's on-duty pharmacies. Locally it worked perfectly. In production, from Vercel, it started failing in strange ways.
Diagnosing it took several commits of pure logging (Add detailed logging to cron and MINSAL fetch, Add more debug details to MINSAL fetch errors, bumping the timeout to 60 seconds): the endpoint sits behind Cloudflare, and datacenter IPs weren't welcome. From a laptop in Chile it went through; from the Vercel function, it didn't.
The fix ended up being routing the request through ScraperAPI with country_code: cl, plus moving the function to the gru1 region (São Paulo, the closest one):
const params = new URLSearchParams({
api_key: SCRAPER_API_KEY,
url: MINSAL_API_URL,
country_code: 'cl',
device_type: 'desktop',
render: 'false',
});
One detail that cost me a full day: my first attempt used an undici proxy, and that does not work in the Vercel runtime. The URL-based approach does, in every environment.
Step 2: never depend on MINSAL at request time
Nobody should have to wait on an external API to find out where there's an open pharmacy. So the site never calls MINSAL when someone visits.
A Vercel cron runs every day at 13:00 UTC (after MINSAL refreshes its data), and that's the only moment the source is touched:
{
"crons": [{ "path": "/api/cron/refresh-turnos", "schedule": "0 13 * * *" }]
}
The cron downloads, validates, normalizes, and stores everything in Vercel KV, already indexed by comuna and by region. Pages only read from there. Three things around it turned out to be what lets me sleep:
- A lock, so two concurrent runs don't step on each other, with a 5-minute minimum interval between refreshes.
- Fallback to yesterday: if MINSAL doesn't respond, the site shows yesterday's data with its date visible, instead of an empty page. Stale-but-labeled data beats "no results found" at 3 AM every single time.
- A history of the last 30 cron runs kept in KV, so I can pull out my phone and see whether today's run passed or failed without digging through Vercel logs.
Step 3: the app people actually use
Browser geolocation, a comuna search box with autocomplete, and a Leaflet map — deferred with next/dynamic({ ssr: false }) and an IntersectionObserver, because the map is the heaviest thing on the page and has no business blocking the only thing that matters: the name, the address, and the directions button.
That button is the heart of the site, and it's deliberately boring: an <a> pointing at google.com/maps/dir/?api=1&destination=lat,lng, which on a phone opens the maps app with the route already loaded. If the pharmacy has no valid coordinates, it falls back to maps/search/?api=1&query=address, comuna, Chile — worse, but it still leaves you looking at a map with the address searched. The phone number sits next to it as a tel: link, and it's optional: some pharmacies publish no number. The route is always there.
At that point I had a product that worked. And for months, almost nobody used it.

That's what Analytics looked like from January through June. Mind the axis: the top of that chart is 150 users. The only peak is the launch, back in February, when I told the story on LinkedIn. After that, flat. A site that worked perfectly and that nobody could find.
Step 4: the wall — a single indexable URL
This was the big lesson of the project.
The site was a very tidy SPA: you picked your comuna, client state filtered the list, the results updated. No reload. Beautiful. And with exactly one indexable URL in the whole site.
Meanwhile, people don't search for "farmacia de turno". They search for "farmacia de turno puerto montt". And my competitors had one URL per comuna.
I was competing with a single page against sites with hundreds.
Step 5: 346 pages that actually exist
I rebuilt the architecture in five milestones, all in June:
- Foundations: a static comuna seed generated by a script (
scripts/build-comunas.ts), cross-referencing the 222 comunas MINSAL had ever reported against the INE/SUBDERE list. Result: 346 comunas. - One ISR page per comuna at
/farmacia-de-turno-[comuna], pre-generated withgenerateStaticParams()and revalidated every 24 hours, matching the actual data cadence. - Nearby pharmacies when a comuna has no published shift, so the page is still worth something. This is where the migraine night lands: if your comuna has no shift — or has one that doesn't help — the site shows you the ones next door without making you start the search over.
- JSON-LD (
Pharmacy,FAQPage,BreadcrumbList) + 16 region pages + breadcrumbs visible in the HTML, not just in the schema. - Dynamic sitemap, internal linking, and per-comuna content.
The technical decision that paid off most: comuna pages are pure Server Components. No 'use client', no onClick. Googlebot gets the full listing — <article>, <address>, and the Maps and tel: links already resolved in the HTML — without running a line of JavaScript. The map is still client-side, but deferred and out of the critical path.
Crawl depth: Home → Region → Comuna. All 346 comunas are two clicks from the home page, none orphaned.
Step 6: the audit that told me what I didn't want to hear
Google started indexing… halfway. A pile of pages stuck in "Discovered — currently not indexed".
I sat down and audited the site cold, as if it were someone else's, and the finding was uncomfortable: 181 of the 346 comunas had never had a shift reported by MINSAL. Their pages always rendered the same block, the same templated paragraph, and the same FAQ, with the comuna name swapped in.
In other words: I had built the exact pattern of low-value programmatic content that Google penalizes. Across 55% of the site.
The fix was small and counterintuitive — taking pages out of the index:
...(isThin && { robots: { index: false, follow: true } }),
follow: true is the important half: the URL stays crawlable and keeps passing link equity, and it comes back into the index on its own the day that comuna gets real data. On top of that: filtering those URLs out of the sitemap so they don't burn crawl budget, and replacing the templated copy with content derived from real per-comuna data (INE population, history of days with a shift).
Step 7: my favorite bug
While prepping the site for AdSense review I found a good one: the 124 comunas that came from the INE list carry a 5-digit INE code, while MINSAL indexes everything by its own fk_comuna. The two ID spaces never intersect.
The result was a page contradicting itself: Arica said "no shift published" and right below it listed an on-duty pharmacy in Arica as "nearby".
The fix: a single matchesComuna() function that joins by ID or by normalized name, at the one point all three routes doing that join pass through (the comuna page, the home search, and the API). One guard in the shared function, not three patches in the callers.
As a bonus, build-comunas.ts now refuses to shrink the seed without an explicit --allow-shrink flag: regenerating it from farmacias.json alone silently deleted 124 already-indexed pages.
The numbers
Google Analytics — January 1 to September 21, 2026:
- Active users: 26,484 (26,317 new)
- Events: 125,078
- Average engagement time: 19 seconds
- Google organic: 24,632 users, 93% of the total
Search Console — the site's entire history, which starts on June 18, 2026:
- Clicks: 25,859
- Impressions: 541,327
- CTR: 4.78%
- Average position: 6.8
- Mobile: 97% of clicks
- Chile: 99% of clicks

The same chart as above, three months later. It is literally the same report: the only thing that changed is that the axis no longer tops out at 150, it tops out at 1,000.
What those numbers actually tell me:
- Search Console history starts on June 18 with 9 impressions — the exact day the comuna pages shipped. Filter by the last 12 months and there isn't a single data point before that date. Month over month: June (13 days) 652 clicks → July 4,082 → August 7,977 → September, 19 days in, 13,148.
- Chile's independence holiday was the load test. On September 17 the site did 975 clicks; on the 18th, with nearly everything closed, 3,491 clicks and 55,341 impressions in one day — almost as many clicks in a single day as in all of July. The 19th: 2,916. The cron and KV held without touching anything.
- The queries that convert are hyperlocal:
farmacia de turno tomegets 60.9% CTR at position 1.8.farmacia de turno concon, 38.8%.farmacia de turno illapel, 55.5%. The genericfarmacia de turnohas 12,608 impressions and 1.74% CTR. The long tail beats the head term by a mile. - The comuna pages are the product:
/farmacia-de-turno-concon(1,593 clicks) and/farmacia-de-turno-tome(1,563) beat the home page (566) three to one. Those are exactly the pages that didn't exist before. - 97% mobile. Obvious in hindsight: nobody looks for an emergency pharmacy from a desktop.
- AI assistants showed up as a traffic source: around 130 users across ChatGPT, Perplexity, Copilot, and Gemini. Small in volume, but it's a source that didn't exist two years ago.
- 19 seconds of average engagement. For this product that's success: you came in, saw the pharmacy, tapped "directions", and drove off. The best possible outcome is that people leave quickly.
Where it stands today
- In production at mifarmaciadeturno.cl, with the cron running daily.
- 372 pages: 346 comunas + 16 regions + guides, legal pages, and the regions hub.
- AdSense approved. The site passed review and now monetizes with non-invasive slots, which was the plan from day one: nothing covering the result, because ads covering the data is exactly what made me build this. To take the shine off the word "monetizes": the September 21 report reads 260 page views, 159 ad impressions, 1 click, and 34 Chilean pesos (about four US cents). This does not pay for the domain yet.
- A guides section with original content (how shifts get assigned, what to do when there's no pharmacy nearby) and an about page with real authorship, both to reinforce E-E-A-T.
- Pending: enabling the EU CMP in the AdSense panel, and enriching the content of the comunas currently set to
noindex.
What I took away
A product that works is not a product that gets found. I had a correct app for months with near-zero traffic, and the change wasn't in the product: it was giving every search intent its own URL.
The data source is the real risk. Almost none of the hard work was in React; it was in making a public API respond reliably from a datacenter in Brazil.
Sometimes better SEO means publishing less. Setting noindex on 181 pages was the most valuable change in the project, and also the smallest.
If the idea is useful to you, the site is up and free. And if you're building something similar and have questions about any part of it, reach out.