aniverse#
A private, Instagram-style photo feed for exactly two people — Anirudh and Anita. Multi-photo posts with captions, locations per photo or per post (Google Places autocomplete, pre-filled from EXIF GPS), comments, emoji reactions (double-tap to ❤️), switchable themes (Pastel / Warm / Brutalist / Noir, in the avatar menu), full-res originals kept forever.
Stack: one Cloudflare Worker serving a React SPA (Vite) + Hono JSON API · D1 (data) · R2 (photo originals + cached variants) · Images binding (resizing) · Google login (Anita) · passkeys (Anirudh) · installable PWA.
Local development#
sh nix develop # node 22 + resvg (or use direnv: `direnv allow`) npm install cp .dev.vars.example .dev.vars # defaults work; Google keys optional locally npm run db:migrate:local npm run dev
Then:
- Easiest login: with
DEV_PASSWORD=devin.dev.vars, the login page grows a "local dev" box — type the password and enter as either Anirudh or Anita. (Only rendered when the endpoint is enabled, and the endpoint only works on localhost.) - Passkey login: visit
http://localhost:5173/setup?key=dev-setup-secretonce to enroll, after that use "Sign in with passkey" on/login. - Google login: needs real OAuth credentials in
.dev.vars(addhttp://localhost:5173/api/auth/google/callbackas an authorized redirect URI on the client). - Location autocomplete: needs
GOOGLE_MAPS_API_KEYin.dev.vars; without it the app works, the pickers just say "search unavailable".
Deploying (one-time checklist)#
- Cloudflare resources
sh npx wrangler login npx wrangler d1 create aniverse # paste database_id into wrangler.jsonc npx wrangler r2 bucket create aniverse-photos npm run db:migrate:remote - Fix the allowlist — in
wrangler.jsonc, replaceCHANGE_ME_anita@gmail.comwith Anita's real Google email. - Google Cloud console (console.cloud.google.com, one project for both):
- OAuth consent screen: External, stay in Testing mode, add both of your emails as test users (no verification needed for 2 users).
- Credentials → OAuth client (Web): authorized redirect URI
https://YOUR_DOMAIN/api/auth/google/callback. Copy client ID + secret. - Enable APIs: Places API (New) and Geocoding API. Create an API key restricted to those two. (Requires a billing account; usage at 2-person scale sits inside the free tier.)
- Secrets
sh npx wrangler secret put GOOGLE_OAUTH_CLIENT_ID npx wrangler secret put GOOGLE_OAUTH_CLIENT_SECRET npx wrangler secret put GOOGLE_MAPS_API_KEY npx wrangler secret put SETUP_SECRET # any long random string - Deploy
sh npm run deploy - Domain first, then passkeys. Passkeys are cryptographically bound to the
hostname — decide the final domain (custom domain or the workers.dev URL)
before enrolling. Then visit
https://YOUR_DOMAIN/setup?key=<SETUP_SECRET>on each of Anirudh's devices (Face ID / Touch ID). Anita just taps "Continue with Google". - Phones: open the site in Safari → Share → Add to Home Screen. It
runs standalone like an app (the future native iOS app will reuse this same
/api).
Costs#
Everything runs on Cloudflare's free tier: R2 has no egress fees, image transformations are cached back into R2 so each photo variant is generated exactly once (well inside the 5,000/month free transformations), and static assets are free. The only external dependency is Google Maps Platform (billing account required, but per-SKU free tiers cover this usage).
Local-first#
- The TanStack Query cache persists to localStorage, so the app paints the last-seen feed instantly on launch and revalidates in the background.
- A service worker (production builds only —
npm run build && npx vite previewto test) precaches the app shell, caches photos cache-first (they're immutable per URL), and falls back to the cached feed when offline. An "offline — showing saved memories" chip appears when disconnected. - Deleting a post is optimistic: it vanishes immediately and reappears if the server call fails.
Notes#
- The whole app is private: every
/api/*and/img/*route requires a session; only allowlisted emails can ever create one. - Originals are stored untouched in R2 under
originals/<photoId>(grab one at/img/<photoId>/original); feed/thumb/full variants are lazily generated webp. - Post timestamps are unix milliseconds in the DB (cursor tie-breaking); the API returns seconds.
- EXIF orientation is handled: display dimensions are stored orientation-corrected, production Cloudflare Images auto-orients pixels, and the sharp-based local simulator (which doesn't) gets an explicit rotation compensation that only activates on localhost.