Overview
Most fitness apps want your data in their cloud. Orbit is the opposite — a personal fitness PWA where the same codebase drives a Supabase-backed cloud deployment and a Docker self-host with local SQLite, with no per-environment branching above the data layer. This writeup covers the dual-storage abstraction, the offline mutation queue, and the provider stack that holds the app together.
Stack
- Framework: Next.js 15.5, React 18, TypeScript 5.7 (strict)
- Styling: Tailwind CSS 3.4
- Database: Supabase 2.89 (cloud) or SQLite via better-sqlite3 (local)
- Data Fetching: @tanstack/react-query 5.90
- Testing: Jest 30 + React Testing Library, Playwright 1.57
- Analytics: Vercel Analytics + Speed Insights
- Deployment: Vercel
Key Features
- Weight tracking with trend visualization
- Meal planning and food logging (OpenFoodFacts integration)
- Workout logging with 30+ pre-built routines
- Recipe browser with create/edit and CSV import
- Pantry inventory management
- Household multi-person support
- Demo mode (works without authentication)
- Offline-first with an IndexedDB mutation queue
- PWA with service worker
Provider Stack
ThemeProvider
→ QueryProvider
→ AuthProvider
→ CSRFProvider
→ PersonProvider
→ ToastProvider
→ ConnectionStatusProvider
→ AppInitializer
Three Auth Paths (PersonProvider)
- Authenticated: loads from API/Supabase. Retries after 500ms if empty (session timing).
- Demo Mode: uses
DEMO_PERSONSfromdemo-data.ts. Full functionality, localStorage-backed. - Non-Authenticated: tries localStorage → API → falls back to demo data.
Offline Support
offline-queue.ts is an IndexedDB-backed mutation queue. Mutations are auto-queued on network failure and auto-replayed when the connection returns. Only 5xx and network errors retry — 4xx is treated as a permanent client error and surfaced.
Dual Database Backend
A single codebase supports both Supabase and SQLite by aliasing fields at the data layer:
| Concept | SQLite | Supabase |
|---|---|---|
| Pantry item | item | name |
| Recipe prep time | prep_time_min | prep_time_minutes |
| Nutrition | top-level fields | nested nutrition object |
Hooks call isSQLiteMode() (driven by an /api/config endpoint exposing a sqliteEnabled flag) and branch between row mappers. The UI layer never knows which backend is live.
Environment Variables
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | Supabase project URL (Supabase mode) |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Supabase anon key (Supabase mode) |
DATABASE_TYPE | Set to sqlite to use the SQLite backend |
DATABASE_PATH | SQLite database location |
DEMO_MODE | Allow unauthenticated access |
The app runs without Supabase vars — it falls back to SQLite or demo mode.
Lessons Learned
- Supabase
navigator.locksdeadlock: Supabase v2.39+ usesnavigator.locksingetSession(). If a tab crashes while holding the lock, every other tab hangs indefinitely. The fix is a custom lock wrapper inlib/supabase.tswith a 5-second timeout. Diagnose by runningnavigator.locks.query()in the console. - Empty load after login:
loadPersons()sometimes returns empty immediately after auth — the session token hasn't propagated yet. Auto-retry after a 500ms delay. - CSRF flow: server generates a token → HttpOnly cookie → client sends it back via
X-CSRF-Tokenheader → middleware validates on mutations only. WrappersauthFetch()andcsrfFetch()keep call sites simple.
Testing
194 unit tests (Jest + React Testing Library) plus Playwright E2E. CI runs lint → test → build on every PR.