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

LayerSourceResponsibility
Typed clientsrc/lib/supabase/client.tscreateClient<Database>() with the keychain-backed auth adapter.
Type mapsrc/lib/supabase/database.types.tsTypes imported as Tables, TablesInsert, and TablesUpdate.
Query clientsrc/lib/query/client.tsOne client: one retry, 30-second staleTime, and no refetch on window focus.
Servicessrc/services/*.tsPure Supabase table, Storage, and function calls; no component imports.
Auth storesrc/stores/auth-store.tsSession, current user, and profile.
Subscription storesrc/stores/subscription-store.tsAccount-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:

PieceImplementation
Servicesrc/services/todos.ts lists, creates, toggles, and deletes public.todos.
Stable cache keytodosQueryKey(userId) returns ['todos', userId].
Orderingsort_order ascending, then created_at descending.
OwnershipThe client sends user_id on insert; RLS ensures it equals auth.uid().
ErrorsService 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:

PieceRule
src/services/subscription.tsReads the signed-in user’s one-row subscriptions record with maybeSingle().
Edge FunctionsCreem webhook and license flows write billing records using server-only credentials.
RLSClients can select their own subscriptions and payments; they receive no client write policy.
Storesubscription-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.

On this page