Product Design Case Study

Mumbai's cat map,
built from scratch

How we designed and shipped Meowmbai — a community-driven platform to discover, map, and celebrate Mumbai's street cats.

100+ Cats mapped
15+ Neighbourhoods
4 Core screens
0 API cost / upload

Mumbai has thousands of street cats. Nobody knows where.

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
😿

Before

Cat sightings scattered across WhatsApp. No location data. No way to discover cats in your neighbourhood. No community record.

😸

After

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.

Three rules we never broke

Early on we set constraints that shaped every screen: the map is the product, friction kills uploads, and no usernames.

🗺️

Map first

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.

2-tap upload

Pick photo → confirm location → done. We removed every optional step. No accounts. No names required. The lower the friction, the more sightings get recorded.

🙈

No usernames visible

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.

📱

Mobile native

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.

🏙️

Mumbai only

Hard geofence. Bangalore photos are rejected at the API level. This makes the dataset dense and trustworthy rather than sparse and global.

🌅

Warm, not clinical

Cream backgrounds, orange accents, rounded corners. The design should feel like a Sunday morning walk, not a utility app. Every detail earns its warmth.

A tight surface area, deliberately

Four tabs. Each one does exactly one thing. There's nothing to configure, no settings page, no profile.

🗺️

Map

All cats in Mumbai, clustered by proximity. Tap a cluster to zoom in or see the cats inside it. Tap a pin to open the cat card.

📸

Feed

Infinite scroll grid of approved cat photos. 5 columns on desktop, 2 on mobile. Lazy-loaded, infinite scroll, no pagination.

Spot

Upload a sighting. Photo → location (EXIF auto-fill or search) → post. AI blocks non-cat images before they hit the database.

🔭

Explore

Neighbourhood leaderboard with expandable cat carousels. Contributors podium. Live proximity detection when location is shared.

🐾

Cat detail

Full-bleed photo, reactions, sighting counter, and Similar Cats — powered by colour-matching to surface visually related cats.

Stack choices that kept costs at zero

Every third-party service was evaluated on one question: is there a free path that doesn't compromise quality? Most of the time, yes.

Frontend

Next.js 15 App Router TypeScript Mapbox GL JS SWR CSS Modules Vercel

Backend

PostgreSQL (Supabase) Drizzle ORM Cloudflare R2 HuggingFace (free) Sharp
🗺️

Map — Mapbox GL JS + HTML markers

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.

🤖

Cat detection — HuggingFace ResNet-50 (free)

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.

🎨

Similar Cats — pixel-average colour matching

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.

📍

Location — EXIF + Mapbox Geocoding

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.

🖼️

Images — Cloudflare R2 + Sharp pipeline

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.

What we changed, kept, and added

Every iteration was driven by a real friction point. Here are the decisions that defined the final product.

Claude API → HuggingFace ResNet-50

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.

Replaced

Viewport-based map API → Load all cats at once

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.

Replaced

Progress bar in upload → Removed

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.

Replaced

Area buckets in Explore → Ranked list with expandable carousel

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.

Replaced

Blob / organic pin shapes → Clean 8px square pins

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.

Replaced

Instagram handle text → Icon only

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.

Replaced

Single colour tag → Multi-colour per cat

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.

Added

Mapbox geocoding search in upload

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.

Added

Hard block non-cat uploads

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.

Enforced

What we locked down

A community platform needs to trust its data. We audited every API endpoint for ownership enforcement before launch.

🔐

Mumbai geofence

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.

🛡️

Admin HTTP Basic Auth

All /admin/* and /api/admin/* routes are behind middleware enforcing HTTP Basic Auth. Password lives only in Vercel environment variables — never committed.

🪪

IDOR prevention

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.

🐾

Fingerprint ownership

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.

What we'd tell ourselves on Day 1

Ship the map first, everything else is decoration The map with working pins was more valuable than a polished upload flow with no data. Get the core loop working before adding layers.
Remove before you add Progress bars, username displays, neighbourhood emojis — each removal made the product cleaner. Every session started by asking "what can we cut?"
Free tiers are good enough until they're not HuggingFace ResNet-50 replaced a paid Claude API call with zero quality loss. Pixel-average colour matching replaced what could have been a DINOv2 pipeline. Solve the problem cheaply first.
Tile cache is the enemy of HTML markers Mapbox's 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.
The counter has to match what you see "99 cats" shown in the corner but only 6 visible on the map was the single most trust-destroying bug. Users noticed immediately. Loading all data at once and letting Mapbox cluster it was the correct architecture from the start.
Design for the user who uploads once Most cat spotters will upload one or two times. The entire upload UX should feel instant and low-stakes for a first-time user, not optimised for a power user who uploads daily.