An offline-first group chat app built with Expo/React Native, using Supabase as the backend and PowerSync for offline sync.
This demo uses Sync Streams (edition 3). User data (profile, contacts, groups, memberships, DM messages) is auto-subscribed, while group messages are subscribed on-demand when a user opens a specific group chat and unsubscribed when navigating away.
The following video gives an overview of the implemented functionality:
PowerChat.Public.Demo.4K.mp4
Run the full backend locally with a single command using the Supabase CLI and Docker.
- Docker (must be running)
- Supabase CLI
-
Start Supabase and PowerSync:
pnpm local:up
This runs
supabase start(which applies all migrations from./supabase/migrations/) and starts a local PowerSync service container via Docker Compose. -
The
.env.localfile is pre-configured to point to the local services.Physical device: Replace
127.0.0.1in.env.localwith your Mac's LAN IP address (the same one shown in the Metro bundler URL, e.g.192.168.x.x). This is required because127.0.0.1on the device refers to the device itself, not your Mac. -
Start the Expo dev server:
pnpm dev
-
Run ios
pnpm ios
-
Run Android
pnpm android
-
To stop everything:
pnpm local:down
| Service | URL |
|---|---|
| Supabase API | http://127.0.0.1:54321 |
| Supabase Studio | http://127.0.0.1:54323 |
| PowerSync | http://127.0.0.1:8080 |
| Inbucket (email) | http://127.0.0.1:54324 |
To run against cloud-hosted Supabase and PowerSync instances:
-
Deploy a Supabase project using the config and migrations in the supabase folder. Update
EXPO_PUBLIC_SUPABASE_URLandEXPO_PUBLIC_SUPABASE_ANON_KEYin .env.local. -
Create a PowerSync instance via the PowerSync Dashboard and connect it to your Supabase project. Deploy the following sync streams configuration (also available in sync-config.yaml):
config: edition: 3 streams: user: auto_subscribe: true queries: - SELECT * FROM profiles WHERE id = auth.user_id() - SELECT * FROM contacts WHERE owner_id = auth.user_id() - SELECT * FROM memberships WHERE profile_id = auth.user_id() - SELECT * FROM groups WHERE owner_id = auth.user_id() - SELECT * FROM messages WHERE sender_id = auth.user_id() - SELECT * FROM messages WHERE recipient_id = auth.user_id() AND sent_at IS NOT NULL - SELECT *, '...' as content FROM messages WHERE recipient_id = auth.user_id() AND sent_at IS NULL contact_profiles: auto_subscribe: true query: | SELECT profiles.* FROM profiles JOIN contacts ON contacts.profile_id = profiles.id WHERE contacts.owner_id = auth.user_id() member_groups: auto_subscribe: true query: | SELECT groups.* FROM groups JOIN memberships ON memberships.group_id = groups.id WHERE memberships.profile_id = auth.user_id() chats: auto_subscribe: true queries: - SELECT profiles.id, profiles.id as profile_id FROM profiles JOIN messages ON messages.recipient_id = profiles.id WHERE messages.sender_id = auth.user_id() - SELECT profiles.id, profiles.id as profile_id FROM profiles JOIN messages ON messages.sender_id = profiles.id WHERE messages.recipient_id = auth.user_id() chat_profiles: auto_subscribe: true queries: - SELECT profiles.* FROM profiles JOIN messages ON messages.recipient_id = profiles.id WHERE messages.sender_id = auth.user_id() - SELECT profiles.* FROM profiles JOIN messages ON messages.sender_id = profiles.id WHERE messages.recipient_id = auth.user_id() group_messages: queries: - SELECT * FROM messages WHERE group_id = subscription.parameter('group_id') AND sent_at IS NOT NULL - SELECT * FROM messages WHERE group_id = subscription.parameter('group_id') AND sender_id = auth.user_id() AND sent_at IS NULL group_memberships: query: SELECT * FROM memberships WHERE group_id = subscription.parameter('group_id') group_member_profiles: query: | SELECT profiles.* FROM profiles JOIN memberships ON memberships.profile_id = profiles.id WHERE memberships.group_id = subscription.parameter('group_id')
Update
EXPO_PUBLIC_POWERSYNC_URLin .env.local.