A quiet anti-social network. Write a message, cast it into the sea, and an hour later it drifts to a random stranger somewhere in the world. No replies. No profiles. No feed. No algorithm deciding who matters.
┌──────────────────────────────────────────┐
│ glassbottles Sent ✓ │
│ │
│ · ✦ . · ☾ · │
│ · . Your bottle │
│ awaits · ✦ . │
│ │
│ Write something for a stranger. │
│ They won't know it's you. │
│ ╭╴🫙 │
│ ╭───────────────────────────────╮ │ │
│ │ Type your message… │ │ │
│ │ │ │ │
│ ╰───────────────────────────────╯ │
│ │
│ ≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈ │
│ ≈≈≈≈ 🍾 ≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈ ≈≈≈≈≈≈≈≈≈≈≈≈≈≈ │
│ ≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈ 🍾 ≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈ │
│ ≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈ │
│ 2 bottles drifting through │
│ the ocean, waiting… │
│ ⏲ next bottle in 14:32:07 │
│ │
├──────────────────────────────────────────┤
│ ⚓ ✉ ⚙ │
│ Bottle Inbox Settings │
└──────────────────────────────────────────┘
Every social platform optimises for engagement: more posts, more replies, more dopamine, more time-on-app. glassbottles optimises for the opposite — scarcity and serendipity.
- One bottle per day. You get a single message to send. That's it. No infinite scroll, no posting spree.
- No replies. You will never have a conversation. A bottle is a one-way message to a stranger you'll never meet.
- No profiles, no followers, no metrics. Nobody can be "big" here. There is nothing to perform for.
- Anonymity is absolute. The sender and receiver of a bottle can never learn each other's identity — it is enforced at the database layer, not just hidden in the UI.
- It makes you wait. A thrown bottle floats in the sea for at least an hour before it's found — even if a perfect match exists the instant you throw it. The waiting is the feature. A message in a bottle that's received instantly isn't a message in a bottle.
It's a place to say the thing you can't say to the people who know you — "I finally told my sister what I'd been meaning to say for three years. It went okay." — and trust the ocean to carry it to someone who needed to read it.
- Sign in with Google or email + password (Supabase Auth).
- On the home screen, the bottle is tethered to a compose box, bobbing on the sea. You write one message and throw it.
- The bottle drops into the sea and drifts — visibly floating, counted in "bottles adrift right now" on the landing page.
- It cannot be found for at least one hour, no matter how many people are online.
- After ~1 hour, the matcher assigns the bottle to a random eligible stranger (someone who hasn't received a bottle on their local day). At that moment:
- the receiver gets an email (branded, via Resend) and an in-app notification,
- your bottle vanishes from your sea and you get a quiet "delivered" signal,
- the message lands in the receiver's Inbox.
- Tomorrow (measured at your local midnight), your daily bottle refills and you can throw again.
You can throw exactly one bottle per day, and receive at most one bottle per day.
| Layer | Choice |
|---|---|
| Framework | Next.js 14 (App Router, React 18, TypeScript) |
| Styling | Tailwind CSS 3, custom ocean / night-sky design system |
| Animation | Framer Motion (the sea, the throw, the wiggling bottle, banners) |
| State / data | Redux Toolkit + RTK Query |
| Backend | Supabase — Postgres, Auth, Realtime, Edge Functions, pg_cron |
| Auth | Supabase Auth (Google OAuth + email/password); middleware verifies JWTs locally via getClaims() (ES256) |
| Resend (SMTP transport + branded HTML templates) | |
| Hosting | Vercel (Fluid Compute), custom domain glassbottles.app |
| Monorepo | pnpm workspaces (apps/web, apps/dashboard) |
| Tooling | TypeScript 5.5, ESLint, Vitest, @vercel/analytics + speed-insights |
Client reads (today's status, received bottles, ambient counts) are SECURITY DEFINER Postgres RPCs called directly from the browser via supabase.rpc(...) in RTK Query queryFns — zero Vercel Function compute. This was a deliberate cost decision: per-tab polling against Vercel /api/* routes once caused runaway Fluid usage. Project rule: never give an RTK Query hook a pollingInterval whose endpoint hits a Vercel route; freshness comes from Supabase-direct RPCs + Realtime, and any poll must pair with skipPollingIfUnfocused: true.
A Postgres trigger (notify_bottle_matched) fires a private Realtime broadcast to the affected user's user:<uuid> topic carrying only a bottle_id (no identities). RealtimeBottleListener invalidates the relevant RTK Query cache on receipt. Realtime is only a refresh hint — every banner/state derives from server truth and also reconciles via a slow focused-tab poll (≤30s) or navigation, so a missed socket event can never lose a notification.
match_bottle() is the single transactional chokepoint for all matching (SELECT … FOR UPDATE, random eligible receiver, receiver-quota write — all in one transaction). It refuses to match any bottle younger than one hour (the "1 hour adrift" rule). Matching is driven by a pg_cron retry job every 15 minutes, so a bottle is found on the first tick after it crosses the hour (~1h00–1h15). There is no instant / send-time match.
"One bottle per day" and the daily reset are measured at each user's local midnight, derived from a stored IANA timezone on their profile (user_local_date()), not a global UTC day.
The landing page's "bottles adrift right now" and "bottles in the sea till now" read a single cached public_stats row, kept live by DB triggers (not per-visitor COUNT(*) scans, not crons):
total_count—+1on every throw, never decremented → cumulative-ever / monotonic.adrift_count— an exact delta trigger overreceived_at IS NULL AND is_stale = FALSE(throw+1, match−1, stale−1, delete−1).
O(1) per event, served from one row → scales to unlimited landing traffic.
Anonymity is not a UI convenience — it's structural:
- Client read RPCs return only anonymity-safe columns.
sender_id/receiver_idnever leave the database. - Realtime payloads carry only a
bottle_id;realtime.messagesRLS restricts each client to its ownuser:<uuid>topic. - Row-Level Security scopes every row to its sender or receiver; column-level grants stop a receiver from rewriting anything but
is_read/is_reported. - Server-only secrets: the service-role key lives exclusively in API route handlers and Edge Functions, never in a
'use client'bundle.
glassbottles/
├─ apps/
│ ├─ web/ # the Next.js app
│ │ ├─ app/
│ │ │ ├─ (app)/ # authed: home, inbox, settings (+ AppShell, BottomNav)
│ │ │ ├─ (auth)/ # sign-in, sign-up, forgot/reset-password
│ │ │ ├─ auth/callback/ # single PKCE code-exchange landing
│ │ │ ├─ api/ # bottles/send, account/delete, profile, whatsapp…
│ │ │ └─ page.tsx # landing page (hero, live counters, example bottle)
│ │ ├─ components/ # bottle/, layout/, shared/ (SailingSea, BottleCanvas…)
│ │ ├─ store/ # Redux slices + RTK Query api (bottleApi, authApi)
│ │ ├─ lib/supabase/ # browser / server / service clients
│ │ └─ middleware.ts # auth gate via getClaims()
│ └─ dashboard/ # internal ops dashboard
├─ supabase/
│ ├─ migrations/ # 001 … 023 (schema, RLS, RPCs, matcher, cron, stats)
│ ├─ functions/ # Edge Functions (match-bottle, notify-receiver)
│ ├─ templates/ # branded auth emails (confirmation, recovery, email-change)
│ └─ config.toml # local Supabase config (do NOT `config push` — see below)
└─ pnpm-workspace.yaml
pnpm install # install workspace deps (Node ≥20, pnpm ≥9)
pnpm dev # run the web app (apps/web) on :3000
pnpm typecheck # tsc across the workspace
pnpm lint # eslint across the workspace
pnpm test # vitestEnvironment: copy .env.example → .env / apps/web/.env.local and fill the Supabase URL + anon key, the service-role key (server-only), and the Resend API key.
- Frontend deploys to Vercel on push to
main(production domainglassbottles.app;test.glassbottles.appfor staging). - Database changes apply with
supabase db push(migrations only — safe). ⚠️ Never runsupabase config push.config.tomlholds local-dev auth values (site_url = http://127.0.0.1:3000, a partial redirect list); pushing it would overwrite the production Supabase Auth config and break login for everyone. Set production email templates / URLs in the Supabase dashboard.- Auth confirmation / recovery links route through
/auth/callback; the production redirect allow-list must includehttps://glassbottles.app/auth/callback.
A glass bottle is fragile, transparent, and carries exactly one message. You throw it without knowing where it lands, and you trust the current. That's the whole app.
Built with intention. No feed, no metrics, no noise — just one bottle, one stranger, every day.