Overview
One Node server holds every provider key. The app's only other direct line is Supabase - the session belongs to the user, not the server.
End to end: of the shops a user could plausibly be shown, the share that came back with a real product and price. Lower than the read rate because most shops just don't stock the plant.
Path of one request
- App Expo / React Native. No API keys.
- Node server One process. Holds every key, spends every credit.
- Providers PlantNet, OpenAI, Google Places, Tavily, Firecrawl.
- Supabase Account, library, photos - and the scrape cache shared by every user.
The stack, in one picture
Four lines and nothing else. The app talks to one server and to Supabase; only the server talks to a paid provider, because only the server has the keys.
Which API is called, and when
- App resizes the photo, then
POST /api/diagnose. Over the size limit is refused before a credit is spent. - PlantNet
/v2/identify/all→ species, genus, confidence. - OpenAI, only when the species is a coin-flip inside a confident genus → re-reads the photo to settle it.
- OpenAI again, photo plus species → condition, issues, treatments, care.
- One JSON answer back to the app.
POST /api/nurserieswith plant, lat, lng, radius → a job id at once.- Supabase cache checked first. A recent identical search returns
resultsinline and stops here. - Google Places
places:searchText→ nurseries in the radius, boxed then cut to the true circle. - Tavily
/extractreads each shop. Firecrawl/v1/scrapeonly when the plain read comes back with no product grid. - OpenAI → product and price pulled from the page text.
GET /api/nurseries/job/:idpolled until done; the result is written back to the cache for the next user.
POST /api/care-plan→ OpenAI once per genus, answering for every growing medium in a single call.POST /api/translate-diagnosis→ OpenAI turns the diagnosis text into Hebrew.- Both land in expo-sqlite on the device, so switching medium or language afterwards is instant and offline.
- @supabase/supabase-js calls Supabase straight from the app: Auth, library rows, photo bucket.
- The session belongs to the user, so routing it through the server would only add a second thing to trust.
- Writes go to Supabase first, device second — a failed write never lands locally.
- The server reaches the same project with a service-role key, but only for the shared scrape cache.
Why one server and not five clients
Keys. Anything compiled into an Expo bundle is readable by anyone who downloads the app. Five provider keys on the device is five keys given away, and every credit spent on them is billed to the project.
Caching. A scrape paid for by one user is worth nothing if the next user cannot see it. The cache only works because every search passes through the same process, keyed the same way.
One thing to watch. GET /health answers for all five providers at once. Spread across clients, an outage is only visible in the reviews.
Providers
Seven external services. Everything else is Expo's own on-device storage.
/health keeps it warm.On device
Routes
Five endpoints plus the job poll. Only the nursery search is asynchronous.
POST /api/diagnosePhoto → species + health. PlantNet, then OpenAI.POST /api/nurseriesplant, lat, lng, optional radius (m, 10km default, 1–50km clamp). Job id back - or, if the same search just ran, results inline.GET /api/nurseries/job/:idPolled until the scrape finishes.POST /api/care-planGenus → watering, feeding, repotting for every growing medium at once.POST /api/translate-diagnosisDiagnosis text into Hebrew.GET /healthCache state, provider last-success, scrape faults.Diagnose a plant
Two provider calls, one round trip.
- Size checked first. Over the limit is refused on the spot, with the size and the reason.
- POST
/api/diagnosePhoto goes to the server. - PlantNet → species.
- OpenAI Same photo plus species → health, issues, care.
- User decides. Saving starts the watering schedule - see Account & data.
Nursery search
The expensive flow. The only asynchronous one.
- POST
/api/nurseriesPlant, location, radius. Job id at once - or finishedresults, if this exact search just ran. - Google Places → nurseries in radius, boxed then cut to the true circle.
- Read each shop Tavily when served plain, Firecrawl when it must be rendered. Platform (Shopify, Wix, WooCommerce…) is remembered per host, so a returning shop skips identification entirely.
- OpenAI → product and price pulled from the page text.
- Poll
/api/nurseries/job/:idResults land on a map.
Where a search goes
Measured 2026-09-13, Tel Aviv 10km, monstera and alocasia regal shield.
4 things worth knowing
Cached across users. A recent scrape for that plant and place skips everything past step 2 - the next person's search costs nothing and takes seconds.
Platform remembered per host. Once a shop's identified as Shopify, Wix, WooCommerce or another platform, that mapping is written to disk - every later search for that shop reuses it instead of re-detecting.
Listed, stock not stated. A page with the plant and a price but no stock line is kept, not dropped - it might still sell the plant.
The radius is a promise. Places only biases location, so a Mitzpe Ramon search came back full of Tel Aviv. Now it's boxed to the circle first, then cut by distance.
Care plan
One OpenAI call per genus per install - not one per plant.
- POST
/api/care-planGenus, and family if known. - OpenAI, once → watering, feeding, repotting for every medium at once: soil, LECA, semi-hydro.
- Cached forever Keyed on genus. Moving mediums reschedules instantly, offline.
Account & data
Accounts are optional. No login wall - signing in only moves where the library lives.
- Logged out Every plant stays on the device.
- Sign-in App → Supabase Auth directly. The Node server is never in this path.
- Existing plants Offered as a one-time import. Declining leaves them where they are.
- Writes Supabase first, device second - a failed write never lands locally.
- Photos Private bucket, short-lived signed URLs. No permanent public link.
- Sign out / delete Wipes the mirror. Deletion cascades the rows and clears the bucket.
Scraping
Every nursery site runs one of a handful of platforms - identify it and the search URL is already known.
- Fingerprint the homepage.
cdn.shopify.com→ Shopify.wp-content→ WooCommerce.wixstatic.com→ Wix. - Reuse that platform's search URL. Shopify
/search?q=, WooCommerce/?s=&post_type=product, Wix/search?q=. - Still unknown? Re-fetch rendered. Some storefronts build markup client-side.
- Still unknown? Ask OpenAI. Classifies from scripts/CDN hints. Written to disk - paid for once.
Who reads the page
81s → 36.5s - what changed
Never concurrency. Firecrawl allows ten requests a minute, and a refused read looks exactly like an unrecognized platform - the path that burns three more requests on the same shop. The limit was feeding itself.
Health
GET /health answers what's otherwise invisible until it shows up on a bill or a bad review.
cache.enabled says so.lastSuccess per provider - an outage is visible before a user hits it.Summary is public. Per-site detail needs the shared secret - an open endpoint shouldn't enumerate which shops get scraped.