Architecture
Three layersโ
- 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. - Managed backend โ Supabase โ database (households, subscriptions), Auth, Row Level Security and Postgres functions (access logic running in the database).
- 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
RSCheader /_rscparam) โ a stale payload would reference the previous deployment's JS chunks and pin the old version. - The
X-App-Buildheader. 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-scrollclips horizontally viaoverflow-x: hidden(notclipโ iOS < 16 ignores that, and withoverflow-y: autoit computes toauto, 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
ScrollToTopOnNavcomponent resets the container's scroll on pathname change (window.scrollTowas tried and made things worse). A query-only change (/?saved=โฆ) does not scroll.