Skip to main content

Architecture

Three layersโ€‹

  1. Frontend (browser) โ€” the whole UI, all calculations (financial health, FIRE, projections, insights), in-app notifications, end-to-end encryption of the data, and local mode (localStorage). Works without a server.
  2. Managed backend โ€” Supabase โ€” database (households, subscriptions), Auth, Row Level Security and Postgres functions (access logic running in the database).
  3. Custom server code โ€” serverless functions (/api/* routes in Next.js) needed wherever a secret or a trust boundary is involved โ€” above all payments (webhook, checkout) and sending email / web push.

What needs a serverโ€‹

  • Payment webhook โ€” verifies the signature and writes subscription status with the service-role key (which must never reach the browser).
  • Creating checkout โ€” calls the provider's API with a secret key.
  • Email / web-push notifications โ€” require scheduling and keys.

What a server is NOT needed forโ€‹

Storing the plan, signing in, financial calculations, insights, in-app notifications and encryption โ€” handled by the frontend + Supabase. The encryption key is derived on the device and never sent to the server.

PWAโ€‹

The manifest + service worker enable installing on a phone and offline use in local mode. They're also the foundation for future web push.

Updates: after a new deployment the fresh service worker waits in the waiting state and the app shows a "New version โ€” Refresh" toast. Clicking sends SKIP_WAITING to the SW and the page reloads onto the new version after controllerchange โ€” the user never silently stays on a stale cache, and we never interrupt their work unasked.

Three guards make sure a tab that lives through a deployment doesn't get stuck on old code (version skew):

  • Update checks on tab focus. The SW registration checks for updates not only at startup but whenever the tab regains focus (throttled), so the "New version" toast also appears in sessions that outlived a deployment โ€” including tabs restored from bfcache.
  • RSC navigation payloads always from the network. The service worker never caches router responses (the RSC header / _rsc param) โ€” a stale payload would reference the previous deployment's JS chunks and pin the old version.
  • The X-App-Build header. The server reports the currently deployed build hash. When a file import fails, the app compares it with the build compiled into the running tab and โ€” on mismatch โ€” asks for a page refresh instead of showing a misleading error.

Top safe area (notch / status bar). The whole layout uses viewport-fit: cover, so content reaches the screen edges โ€” the top gap below the status bar (clock, network, battery, notch / Dynamic Island) comes from env(safe-area-inset-top). On some iPhones WebKit can report that value as 0 (both in Safari and in the home-screen PWA), which pushed the header and the floating buttons under the status bar. So the top inset has a guaranteed minimum: the CSS variable --safe-area-top = max(env(safe-area-inset-top), 1.5rem). On healthy devices it's a no-op; when env wrongly reports 0, the layout still keeps a safe gap. We use it everywhere the top edge matters (app shell, full-screen screens, the โš™๏ธ / invite buttons, slide-in panels).

Scrolling on mobile. The only scroller is the .app-scroll container (position: fixed; inset: 0) โ€” not the window (body { overflow: hidden }). Two consequences:

  • No horizontal scroll. .app-scroll clips horizontally via overflow-x: hidden (not clip โ€” iOS < 16 ignores that, and with overflow-y: auto it computes to auto, bringing back a horizontal bar).
  • New page always starts at the top. Next.js's default scroll-to-top targets the window, so it does nothing here โ€” the ScrollToTopOnNav component resets the container's scroll on pathname change (window.scrollTo was tried and made things worse). A query-only change (/?saved=โ€ฆ) does not scroll.