Endeo runs with no account, no network, and no backend configured
Most apps treat offline as a degraded state. There's a real app that talks to a server, and then there's a sad cached version of it that appears when the network drops, usually held together by a spinner and a stale flag. Endeo was built the other way around: the local store is the app, and the backend is an optional sync target that may never be configured. That inversion changes more code than it sounds like it should.
A fallback is not a default
The difference isn't philosophical, it's structural. If offline is a fallback, every feature has two code paths, and the offline one is the one nobody tests. It gets written second, it gets a lower bar, and it rots quietly until a user on a plane files a bug that takes a day to reproduce.
If offline is the default, there's one code path. Reads come from local storage, always. Writes go to local storage, always. Sync is a separate concern that reconciles local state with a remote in the background, and it is allowed to be absent. Nothing in a view model asks whether the network is up, because nothing in a view model would do anything different if it knew.
This is a bigger commitment than adding a cache. It means accepting that the server can't be the arbiter of truth for anything the user can do without it, which in turn means thinking about identity, ordering, and conflict up front rather than after the first sync bug.
The build with nothing configured
The test we hold ourselves to is this: clone the repo, generate the project, build, run. No environment file, no Supabase project, no keys. The app launches, opens a bundled set of sample chapters, and you can read, highlight, and take handwritten notes. Nothing displays an error, because nothing is broken.
That started as a convenience and turned into the most useful architectural constraint in the project. It makes onboarding a new developer a two-minute job instead of an afternoon of credential-chasing. It makes App Store review straightforward, because a reviewer who never signs in still sees a functioning app rather than a login wall. And it makes demos work in buildings with bad wifi, which is most buildings.
The reason it works as a constraint is that it's load-bearing and it's checked constantly. If someone reaches through the local layer and calls the network directly, the no-backend build breaks immediately and visibly. The boundary can't drift, because the cheapest build in the project is the one that proves the boundary still exists.
- Network path built first, offline patched in
- Account required before anything works
- Failure state is a spinner
- The empty case is discovered in production
- Local storage is the source of truth
- Anonymous session, upgradeable later
- Runs fully with no backend configured at all
- Sync is an enhancement to something already working
The test isn't whether it handles being offline. It's whether it degrades to useful rather than to a spinner.
SwiftData is the database, Supabase is a sync target
Locally, Endeo uses SwiftData. Not as a cache with a TTL, as the actual store: notes, highlights, sessions, and the ingested chapter text all live there and are queried there. The UI binds to local models and never waits on a request to render.
The remote is Postgres on Supabase with row-level security. Its job is durability across devices and anything genuinely server-side, like the Edge Function that calls Claude to summarize a study session. That summarize call is the clean example of the split: it obviously requires a network, so it's a feature that's simply unavailable without one, rather than a feature that breaks without one. Unavailable is a state you can design. Broken is not.
The thing that made this tractable was giving every record a client-generated identifier at creation time. A note has a stable id the instant it exists, before any server has heard of it, so local references between records are valid immediately and sync never has to rewrite keys it already handed out. Server-assigned ids would have forced a fixup pass through the entire local graph on first sync, and that pass is where this kind of architecture usually goes wrong.

Anonymous auth that upgrades later
Supabase anonymous auth is the piece that makes the account question go away. On first launch, if a backend is configured, the app signs in anonymously. The user does nothing and notices nothing, but they now have a real user id, which means row-level security policies apply from the very first row written.
The alternative most apps pick is a local-only mode with no user id, then a migration when someone eventually signs up: walk every local record, stamp it with the new owner, push it, hope nothing was missed. That migration is a one-time script that runs on real user data on the worst possible day, which is the day someone finally decided to trust you with an email address.
With anonymous auth, signing up is an upgrade of the existing identity rather than the creation of a new one. The rows already have the right owner. The user id doesn't change. Nothing moves. The account screen stops being a gate at the front of the app and becomes what it should be, which is a settings row you visit when you want your notes on a second device.
Veto makes the same call in a different shape
Veto is a group decision app, so it's realtime and inherently multiplayer, which sounds like the opposite case. It still ships a pass-and-play mode with no accounts and no network at all: one phone goes around a table, each person enters their vetoes, the device holds the secret between turns, and the result is computed locally.
That mode isn't a consolation prize for people with bad signal. It's how the app gets used at a table where everyone is already sitting together, and it's the fastest way for a group to try the product without four people creating accounts first. It also gave us a second, independent implementation of the core rules, which caught a real ambiguity in how ties resolve before either version shipped.
The pattern in both apps is the same. Ask what the app can honestly do with nothing but the device in someone's hand, build exactly that, then add the network as something that makes it better rather than something that makes it work.
What it costs
Sync is real work and you don't get to defer it. Ordering, conflicts, deletes that race against edits, and the question of what happens when the same account writes from two devices while both are offline are all problems you now own explicitly instead of accidentally. The upside is that you own them in one place rather than scattered through every feature.
You also pay for the bundled content. Shipping sample chapters in the app means a larger binary and a build step to prepare that payload. For a reading app that tradeoff is obvious. For an app whose data is genuinely per-user and unpredictable, there may be nothing sensible to bundle, and the no-backend build becomes an empty state rather than a working app.
What we'd push back on is the assumption that this is only worth it for apps used in remote places. The offline default made the app faster in normal conditions, made the codebase smaller by deleting a second set of loading states, and removed an entire category of flaky test. Bad signal was the reason we started. It stopped being the reason we'd keep doing it.