Tinder-style review tool for 3BR SF apartments under $9k/mo. Scrapes daily, emails new finds, lets you swipe left/right/up on the web.
- Right swipe / → : Yes
- Left swipe / ← : Nope
- Up swipe / ↑ : Maybe (uncertain)
Sources: Craigslist (RSS, reliable), Zillow, Redfin, Realtor.com, Zumper, Apartments.com, Trulia, PadMapper, HotPads, Facebook Marketplace, californiaapartments.com (via Apify, token optional).
Deduplicates across sources, tracks every price seen, geocodes via OpenStreetMap, categorizes by SF neighborhood, marks listings unavailable after 3 days of not appearing in any reliable source.
- Next.js 15 (App Router) on Vercel
- Postgres on Supabase (free tier, 500 MB is plenty)
- Drizzle ORM
- Resend for daily digest email
- Leaflet + OpenStreetMap for maps (zero API keys)
- framer-motion for swipe gestures
npm install
cp .env.example .env
# fill in DATABASE_URL, DIRECT_URL (from Supabase), RESEND_API_KEY, CRON_SECRET
npm run db:push # creates tables in Supabase
npm run scrape # populate listings (Craigslist works out of the box)
npm run devOpen http://localhost:3000.
- Create a project at https://supabase.com (free tier).
- Project Settings → Database → Connection string → pick:
- Transaction mode (port 6543) → paste into
DATABASE_URL - Session mode (port 5432) → paste into
DIRECT_URL(used by drizzle-kit migrations)
- Transaction mode (port 6543) → paste into
- Run
npm run db:pushto apply the schema.
npx vercel link # link to (or create) project "sf-apt-finder"
npx vercel env add DATABASE_URL production
npx vercel env add DIRECT_URL production
npx vercel env add RESEND_API_KEY production
npx vercel env add CRON_SECRET production # any long random string
npx vercel env add SITE_URL production # https://apt-tinder.viraat.dev
npx vercel env add EMAIL_FROM production # see Resend section below
npx vercel env add EMAIL_TO production # viraat.laldas@gmail.com,sambruns2000@gmail.com
npx vercel --prodIn the Vercel dashboard → Project → Settings → Domains → add apt-tinder.viraat.dev.
Vercel will tell you the CNAME to add at your DNS provider (cname.vercel-dns.com).
vercel.json schedules /api/cron at 5 14 * * * UTC = 7:05 AM PT during PDT (Mar-Nov).
During PST (Nov-Mar), 7:05 AM PT is 5 15 * * *. Vercel Cron uses UTC and does not auto-adjust
for DST, so adjust manually twice a year or leave it landing at 6:05 AM half the year.
Note: Hobby plan caps function timeout at 60s. The full scrape may exceed that. Upgrade to Pro for 300s, or trim sources / move heavy scrapes to a separate worker.
The Resend API key in .env.example is already wired up. To send to external emails (not
just your verified Resend account), you must verify a sending domain at
https://resend.com/domains. Add the DNS records, then set:
EMAIL_FROM="SF Apt Finder <apts@yourdomain.com>"
Until then, sends from onboarding@resend.dev only reach the Resend account owner email
(Resend's free-tier sandbox).
| Source | Method | Works out of box? |
|---|---|---|
| Craigslist | RSS | ✅ Yes |
| Zillow | JSON endpoint | |
| Redfin | gis-csv endpoint | |
| Realtor.com | RDC GraphQL | |
| Zumper | Public search API | |
| Apartments.com | Apify actor | 🔑 Needs APIFY_TOKEN |
| Trulia | Apify actor | 🔑 Needs APIFY_TOKEN |
| PadMapper | Apify actor | 🔑 Needs APIFY_TOKEN |
| HotPads | Apify actor | 🔑 Needs APIFY_TOKEN |
| Facebook Marketplace | Apify actor | 🔑 Needs APIFY_TOKEN |
| californiaapartments.com | Apify actor | 🔑 Needs APIFY_TOKEN |
To enable the Apify-backed sources, sign up at https://apify.com (free tier ~ 1k actor runs/mo),
grab a token, and set APIFY_TOKEN. Actor IDs are in lib/scrapers/apify.ts.
If a source goes silent the orchestrator logs the failure to scrape_runs and keeps going,
nothing else breaks.
Across sources, listings are merged by canonical ID:
addressLine + zip + bedrooms(normalized) if address is presentlat,lngrounded to ~10m + bedrooms + sqft if only coordinates- Otherwise per-source unique ID
When the same place is listed at different prices, we store every price in prices_by_source
and use the lowest as the canonical price. The swipe card surfaces all prices side by side.
Each scrape run, the orchestrator:
- Upserts every listing seen (revives
unavailableones back toavailable) - After all scrapers finish, if any reliable source succeeded, marks any listing
not seen in 3+ days as
unavailable.
Unavailable listings:
- Are filtered out of the swipe deck and map by default
- Still appear in your
/likedshortlist, greyed out with a "No longer listed" badge, so you don't lose history.
# Run the scrape locally and (optionally) send the digest email
npm run scrape -- --email
# Trigger a remote run on Vercel
curl -H "Authorization: Bearer $CRON_SECRET" https://apt-tinder.viraat.dev/api/cronapp/
page.tsx - Swipe deck (default route)
map/ - Map view, sidebar grouped by neighborhood
liked/ - Yes / Maybe / Nope columns
api/cron/route.ts - Daily scrape + email
api/listings/route.ts - Listings feed
api/decisions/route.ts - Save / undo swipes
lib/
db/ - Drizzle schema + client
scrapers/ - One file per source + orchestrator
dedup.ts - Canonical ID + merge
geocode.ts - Nominatim wrapper (rate-limited)
neighborhoods.ts - SF bbox lookup
email.ts - Resend digest composer
components/
swipe-deck.tsx - Tinder UI w/ left/right/up gestures
listing-map.tsx - Leaflet map + price pins
vercel.json - Daily cron