How we designed and shipped Meowmbai — a community-driven platform to discover, map, and celebrate Mumbai's street cats.
Cat lovers, feeders, and rescuers in Mumbai had no shared way to document sightings. Photos stayed in WhatsApp groups. Locations were described in words. There was no map, no archive, no community layer.
"We needed something as simple as Instagram but with a map at its core — so anyone could spot a cat, pin it, and the whole city could see it." — Product vision, Day 1
Cat sightings scattered across WhatsApp. No location data. No way to discover cats in your neighbourhood. No community record.
A live map of every spotted cat in Mumbai. Browse by neighbourhood. Upload in under 60 seconds. See what's near you in real time.
Early on we set constraints that shaped every screen: the map is the product, friction kills uploads, and no usernames.
The map isn't a feature — it's the reason the app exists. Every decision was evaluated by how it served the map. The feed, explore, and upload all orbit the map view.
Pick photo → confirm location → done. We removed every optional step. No accounts. No names required. The lower the friction, the more sightings get recorded.
Cat names, yes. Human names, no. The product celebrates the cats, not the contributors. This kept the tone playful and removed social anxiety from uploading.
Over 90% of sightings happen on a phone. The entire interaction model — bottom nav, sheet cards, carousels — was designed for one-handed use on iOS and Android.
Hard geofence. Bangalore photos are rejected at the API level. This makes the dataset dense and trustworthy rather than sparse and global.
Cream backgrounds, orange accents, rounded corners. The design should feel like a Sunday morning walk, not a utility app. Every detail earns its warmth.
Four tabs. Each one does exactly one thing. There's nothing to configure, no settings page, no profile.
Every third-party service was evaluated on one question: is there a free path that doesn't compromise quality? Most of the time, yes.
All cats load in a single API call. Mapbox clustering handles display at every zoom level. HTML markers (not GL layers) let us render React components as pins — cluster bubbles with photo thumbnails, individual square pins with cat photos.
Every uploaded image is sent to Microsoft's ResNet-50 model via HuggingFace's free inference API. Top-5 ImageNet predictions are checked against a cat label set. Score ≥ 0.30 → approved. Below 0.10 → hard blocked with an inline error before anything touches the database.
No ML model needed. Each cat image is resized to 8×8 pixels via Sharp, the dominant hue extracted via HSL analysis, and stored as a colour tag (white / grey / orange / brown / tabby / black / other). Similar Cats filters by matching colour tag — free, instant, surprisingly effective.
Uploaded photos are read for EXIF GPS. If found and within Mumbai bounds, it pre-fills the location step. Otherwise, users type a place name and Mapbox Geocoding returns precise coordinates — scoped to Mumbai's bounding box so Bangalore is structurally impossible.
Photos are compressed client-side, uploaded to R2 via presigned URLs (server never touches the binary), and processed through a Sharp pipeline that produces a 600px/85q thumbnail stored alongside the original. The CDN URL is what gets pinned to the map.
Every iteration was driven by a real friction point. Here are the decisions that defined the final product.
Initially used Claude's vision API to detect cats. Worked well but cost money per upload. Switched to HuggingFace's free ResNet-50 inference — equally effective at distinguishing cats from non-cats, zero ongoing cost.
The map originally fetched cats within the visible bounding box. This meant zooming into Bandra only showed Bandra cats — even though the counter showed 99. With only ~100 cats, one API call loads everything and Mapbox clustering handles the rest.
The upload flow originally had a 4-step progress indicator. After collapsing the flow to a single meaningful step (photo → location → post), the progress bar communicated false complexity. Removed entirely.
The original area view showed each neighbourhood as a card with an orange progress bar. Users asked: "what's the point of the bar?" Replaced with a numbered rank list (1–15) where tapping any neighbourhood reveals a horizontal photo carousel of its cats.
First iteration used animated liquid blob morphing for cat pins. Looked impressive in isolation, looked cheap at map scale with 50 pins visible. User feedback: "very ugly." Switched to a simple 52×52px square with 8px radius and a drop shadow.
The contributors leaderboard showed "@handle" text next to each name. Cluttered the layout and duplicated information. Replaced with a single Instagram icon button — tapping opens the profile. The name is the identity; the icon is the action.
The original colour model stored one tag per cat. A tabby cat is orange AND striped. The admin colour training tool was updated to support comma-separated multi-colour tags, and the Similar Cats query was updated to use ILIKE matching.
Originally users picked from a hardcoded list of neighbourhood chips. Added a live search box backed by Mapbox Geocoding, scoped to Mumbai's bounding box, so users can type any street, landmark, or building and get precise coordinates.
First version sent every photo through for human review. Changed to hard-reject at API level: if ResNet-50 confidence in any cat label is below 0.10, the upload returns 422 with "That doesn't look like a cat 🐾" before anything is saved to the database.
A community platform needs to trust its data. We audited every API endpoint for ownership enforcement before launch.
Three layers: EXIF GPS check client-side, geocoding bbox on Mapbox, and Zod validation server-side. A Bangalore upload is rejected at all three levels.
All /admin/* and /api/admin/* routes are behind middleware enforcing HTTP Basic Auth. Password lives only in Vercel environment variables — never committed.
The detection status endpoint (the only one exposing per-post internal data) now requires the caller's browser fingerprint to match the one stored at upload time.
No user accounts, but each device generates a stable anonymous fingerprint stored in localStorage. Upload actions are tied to this fingerprint at the database row level.
querySourceFeatures() returns empty until tiles are in the render cache. The right fix is a polling loop + never cleaning up markers when you get an empty result. This took three attempts to get right.