← All articles

Swipes instead of arguments: WhatToDo architecture on Vue 3 + Supabase realtime

How the "Tinder for ideas" works: Postgres data model, realtime sync for two swipers, packaging the web app for Android with Capacitor.

whattodo development vue supabase

What you will find in this article

  • "room + members + swipes" model;
  • realtime sync of two devices via Supabase;
  • matching logic (when a choice "matches");
  • packaging web code as an Android app via Capacitor;
  • what we would change if starting over.

This is a technical article — a follow-up to how we launched WhatToDo on Google Play.

Stack in one line

Vue 3 + TypeScript + Pinia + Vite on the front. Supabase (Postgres, realtime, auth) on the back. Firebase Analytics + Crashlytics. Capacitor to package for Android.

Data model

Minimal Postgres schema:

  • rooms — couple session room.
  • room_members — who is in the room (1–2 users).
  • ideas — static (at launch) deck of ideas.
  • swipes — one row: room_id, user_id, idea_id, direction (like/skip).
  • matches — match: room_id, idea_id, created_at.

We intentionally keep matches as a separate table, not a flag on swipes. That allows:

  • instant load of "already matched" when returning to a room;
  • realtime subscription only to matches, without noise from all swipes.

Realtime on Postgres

Supabase provides a postgres_changes channel. Pseudo-code for client subscription to room matches:

const channel = supabase
  .channel(`room:${roomId}`)
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'matches',
      filter: `room_id=eq.${roomId}`,
    },
    payload => onNewMatch(payload.new),
  )
  .subscribe();

When the second participant swipes an idea the first already liked, a server function inserts into matches. Both clients get the event and show the "matched" screen.

Server-side matching logic

The matcher lives in a Postgres function so we do not depend on live clients. A trigger on INSERT INTO swipes does:

  1. finds the "paired" swipe from the other room member on the same idea;
  2. if found and both direction = 'like' — inserts matches;
  3. if already exists — ignores (unique index (room_id, idea_id)).

That gives two important properties:

  • symmetry: match appears the same regardless of who swiped second;
  • idempotency: repeat swipe does not create a duplicate.

What lives in Pinia

Client store has three areas:

  • room — current room, connection status, second participant;
  • deck — local deck (preloaded so swipes do not wait on network);
  • matches — match array with timestamps from the realtime channel.

Local deck is key UX. A swipe must feel instant even on bad network. Server requests run in the background.

Authentication

At launch we support two flows:

  • Google Sign-In — fastest entry. Enough for most Android users with one tap.
  • Email + confirmation — for pairs where a partner has no Google account or prefers not to link it.

Implementation is standard Supabase Auth. No custom JWT or sessions — saved weeks of work and audit.

Telemetry, crash, analytics

  • Firebase Analytics — "room created", "room joined", "swipes per session", "match".
  • Firebase Crashlytics — minimum for closed test. Play Console shows crash rate, but Crashlytics gives stack traces.
  • Supabase logs — server errors and slow queries.

Events use wtd_ namespace so they do not mix with possible shared 3vstyle.ru analytics later.

Android packaging: Capacitor

Build is straightforward:

npm run build         # Vite builds dist/
npx cap sync android  # sync dist to android/
cd android
./gradlew bundleRelease

Output — .aab uploaded to Play Console.

Capacitor runs WebView with locally packaged web build. That means:

  • no CORS issues — web bundle loads from a file://-like scheme;
  • external requests (Supabase, Firebase) go as normal fetch;
  • platform features (push, share) — via Capacitor plugins.

What we would do differently

  • TypeScript-first from day one. We started mixed (some Vue in JS), then caught up. Should have from the start.
  • Supabase Row Level Security immediately. We enabled RLS late. Painful refactor; on a new project RLS is the first thing we turn on.
  • Fewer Capacitor plugins. Each plugin = risk something breaks after an Android target update. Fewer externals = calmer life.
  • Sentry alongside Crashlytics. Crashlytics is good for native crashes, but Sentry catches web exceptions in WebView more precisely.

If you want something similar

This architecture (web + Supabase + Firebase + packaging) fits many "two-user" products: couple apps, team mini-games, light SaaS. It is what we build for clients as a studio.

Try the app — WhatToDo page. Process chronicle — Telegram channel.

Read next