Data Layer
Typed Supabase services, TanStack Query, and the backend swap-point.
The template keeps backend access deliberately small: typed Supabase calls live in service modules, TanStack Query owns server-cache behavior, and zustand stores coordinate session and entitlement state across routes. Services are the place to replace Supabase with your own API later.
Stack
| Layer | Source | Responsibility |
|---|---|---|
| Typed client | src/lib/supabase/client.ts | createClient<Database>() with the keychain-backed auth adapter. |
| Type map | src/lib/supabase/database.types.ts | Types imported as Tables, TablesInsert, and TablesUpdate. |
| Query client | src/lib/query/client.ts | One client: one retry, 30-second staleTime, and no refetch on window focus. |
| Services | src/services/*.ts | Pure Supabase table, Storage, and function calls; no component imports. |
| Auth store | src/stores/auth-store.ts | Session, current user, and profile. |
| Subscription store | src/stores/subscription-store.ts | Account-bound entitlement state. |
Components should not scatter supabase.from(...) calls. Put each feature’s
data access in a service, then use it from a query, mutation, or small store
action. This preserves one tested, typed boundary per backend concern.
Todos: the worked example
The Showcase Todos demo is a complete owner-scoped feature:
| Piece | Implementation |
|---|---|
| Service | src/services/todos.ts lists, creates, toggles, and deletes public.todos. |
| Stable cache key | todosQueryKey(userId) returns ['todos', userId]. |
| Ordering | sort_order ascending, then created_at descending. |
| Ownership | The client sends user_id on insert; RLS ensures it equals auth.uid(). |
| Errors | Service functions throw Supabase errors, allowing UI code to show localized feedback. |
The app’s RLS policies, not an ID hidden in the UI, are what stop one user from reading or changing another user’s todo. Use the SQL on Supabase Setup as the pattern for your own tables.
Profile and avatar services
profile.ts selects one profile by user ID and updates the editable fields.
avatar.ts validates the source bytes, uploads to the private avatars bucket,
then saves the Storage path in profiles.avatar_url. To display it, the service
converts the path to a seven-day signed URL. Removing an avatar deletes the
object first and then clears the profile field.
That sequence means a database row never needs a publicly readable image URL.
It also makes the Storage folder policy simple: a user can operate only in their
own <user-id>/ folder.
Subscription read model
Billing treats Supabase as the source of truth for ownership:
| Piece | Rule |
|---|---|
src/services/subscription.ts | Reads the signed-in user’s one-row subscriptions record with maybeSingle(). |
| Edge Functions | Creem webhook and license flows write billing records using server-only credentials. |
| RLS | Clients can select their own subscriptions and payments; they receive no client write policy. |
| Store | subscription-store.ts derives disabled, free, active, expired, or unknown. |
unknown matters: a network or RLS failure must not silently become free
(which hides a paid user’s access) or active (which unlocks a feature without
proof). When billing is disabled, the store is disabled and makes no backend
read. See Billing for the entitlement gate and
Creem Setup for the writer side.
Add a feature end to end
Create a table, indexes, and owner-scoped RLS policies in Supabase. Treat the desktop webview as an untrusted public client.
Regenerate src/lib/supabase/database.types.ts from your project. Keep the
generated types and SQL change together in version control.
Add a focused module under src/services/. Keep table and Storage calls,
selected columns, and error normalization there.
Use a stable TanStack Query key and a query/mutation or store action in the route. Invalidate or update the appropriate user-scoped cache entry after a mutation.
Add the route, navigation, translations, and signed-out behavior. If an
external deep link should open it, add its exact path to
NAVIGATION_ROUTES as well.
Offline and network behavior
src/hooks/use-online-status.ts uses the webview’s online and offline
events through useSyncExternalStore; the shell uses it to show an offline
banner. It reports the OS network interface state, not proof that Supabase
is reachable. The Query client retries a failed request once and does not
persist its cache to disk.
For account or paid state, model a failed read explicitly. The subscription
store’s unknown state is a good default: show a neutral retry state rather
than guessing the user’s access.
