Expo’s zero-config builds and OTA updates allow you to focus on your product, not the platform you’re building for. Unfortunately, Expo’s powers are limited to the frontend. Even a reasonably simple backend architecture can reintroduce stress and drudgery just when you got into frontend development flow (H/T to Instant for doing a great job explaining how).
The good news is that having a real-time synced database live inside your Expo app can solve most of this pain. There are a few ways to implement that. Our approach is to keep your backend database of choice and simply provide a sync engine that syncs that with in-app SQLite.
The network tarpit
The network management work required for modern apps seems simple at first but you can quickly get caught up in it. That’s why we call it the network tarpit: initially it looks very doable, but it easily becomes complex. Once you’re in, struggling to escape is a good way to sink further. It’s better to avoid the network tarpit altogether, and that’s where synced in-app SQLite comes in.
Instead of having to:
- manage network requests
- handle loading states
- implement retry logic
- orchestrate state management between your local cache and remote data,
You run local SQLite queries against data that's automatically synced in the background.
Instead of having to:
- think about online/offline states
- implement optimistic updates
- manage/wrangle API endpoints
You just write queries.
There's also a UX benefit of instant data access with zero latency. Your app feels responsive because all reads happen locally against SQLite. Users can work offline without even noticing. When they come back online, their changes sync automatically. No more loading spinners, no more "check your connection" errors, just a smooth experience that works.
In this post, we’ll walk through exactly how to combine PowerSync and Expo (and, in this case Supabase as our backend database), to bring flow state joy to full stack development.
What is PowerSync?
PowerSync embeds SQLite in your application and automatically keeps it in sync with your backend database. It’s a sync engine that consists of two components: a service that enables high-scalability partial data syncing and a set of client SDKs that manage client-side persistence, consistency, reactivity and syncing write operations back. PowerSync supports Postgres, MongoDB, and MySQL backend database.
Even though SQL is probably older than you are, it’s still the go-to mechanism for expressing most data querying needs.
For each platform that PowerSync supports – a native SDK is implemented which exposes the API for manipulating the SQLite database’s data and connecting to a PowerSync Service. Lower-level common code is packed into a core SQLite extension which is loaded into the app’s SQLite database. Local writes are queued in an upload table which is processed in your connector implementation. This allows you to write each change to your backend. Changes are synced down from the PowerSync Service to your in-app databases.
Expo applies a +10 Developer experience buff to PowerSync
Expo is the most developer-friendly way to build React Native applications, smoothing the edges of the ecosystem through SDK modules and development tools - with Expo Go being one of the standout boons. It allows you to quickly spin up a sandbox of your application without having to make a development build. This skips the native build step which means faster initial setup and iteration cycles, you don’t even need to have Xcode or Android Studio installed. We have recently added support for Expo Go through our @powersync/adapter-sql-js package built on top of SQL.js.
For PowerSync, Expo becomes particularly useful when accessing native device features like the file system, where our attachment helper package leverages the Expo FileSystem module to implement a storage adapter.
Project: What’s on your mind?
Today we’re building a thought journaling app with PowerSync, Expo, and Supabase that allows other users to react to your thoughts with emojis 😅. PowerSync will handle storing our app data locally in SQLite while automatically syncing everything with a Supabase backend.
Client-Side Implementation
For this tutorial, we'll use a self-hosted backend setup with Supabase as our database solution. Supabase is an excellent choice because it exposes a client-side SDK that allows you to upload changes directly to your database. This is all that’s needed to support multiple clients connecting and syncing data with each other. To simplify auth, we’ll make use of Supabase’s anonymous auth feature. This setup assumes you have Docker and the Supabase CLI installed.
We’re building a synced app, but it’s also possible to use PowerSync without any backend whatsoever, and add sync later - for example as part of a plan upgrade user flow. See our docs for details.
To start we can create our app project, the tailwind template works well for our needs.
There are a few dependencies we’ll need for the client.
Since we want to configure syncing from the get-go, we can use a self-host helper project. It includes a docker config that runs PowerSync and Supabase locally. Degit allows us to clone the code from a repo without all the git history, we will be pulling our community repo.
Note that all code snippets mentioned below are also available in the /backend/client directory if you want to just copy and paste entire files.
Next, let’s create the environment files for the client and backend (Our environment files assume default configuration values exposed by Supabase here and here).
The Expo app’s environment file:
The PowerSync Service needs to know the Supabase JWT secret:
Time to add the AppSchema, SupabaseConnector, and SystemProvider implementations for our app to src/powersync.
→ App Schema
The client-side schema will include all tables you’d like in the SQLite database embedded in your React Native application. Sync Rules defined on your PowerSync Service instance give you control over which data is replicated to those tables. It is not necessary to specify an id column for any table as that is automatically created by the SDK.
→ Supabase Connector
Any PowerSync application that wants to sync data needs a backend connector that provides the connection between the PowerSync Client SDK and your backend. It has two responsibilities: authentication (through fetchCredentials()) and uploading client-side data updates (through uploadData()). Our implementation is leaning heavily on the Supabase SDK, but you could easily swap in your own integration.
For authentication, your backend application needs to generate JWTs that the PowerSync Client SDK can retrieve and use for authentication against your PowerSync Service instance. If you're using Supabase this isn't needed and we can simply re-use the Supabase JWT.
For uploading data, the SDK exposes a mechanism to process local writes. You are in complete control of what should happen to each write (they can be applied to your backend or discarded based on some condition). You might be thinking about what happens in cases where multiple clients have conflicting local writes. The standard approach is “last write wins”. We have documented how you can approach it here.
If you are new to Supabase it might seem scary to be uploading to your backend database directly, but understanding how Row Level Security works will put you at ease.
→ System Provider
This provider creates our PowerSync client and configures it with the app schema and database adapter. To use PowerSync with Expo Go we use a JS-only adapter (imported from powersync/adapter-sql-js). This setup is great for development but we recommend switching to our OP-sqlite or RNQS adapters when making production builds as they give substantially better performance.
Import and use the SystemProvider in the root layout.
Replace the entire index.tsx file’s contents. For reactivity, we use the useQuery() hook from @powersync/react-native that executes the SQL read query, and re-executes whenever the underlying tables of the query have changes.
For write queries, we access the PowerSync client with usePowerSync() and call execute() on it to insert and delete rows.
At this point we could run the client, but without a backend nothing is going to work as our implementation depends on at the very least getting a user ID from Supabase.
Configuring the Sync Backend
With our client setup completed, the next step is to spin up our backend. We’ll start by navigating to the backend directory we pulled with degit and get to booting Supabase through its CLI.
The backend project includes a supabase directory which contains configuration for our Supabase instance. The most important entries are:
- The migration script which sets up our database schema and publication (needed to get changes from the source database to PowerSync). The schema consists of two tables: one for thoughts and another for reactions, where each reaction has a relationship to a thought.
- The base
config.toml, with the only alteration being thatenable_anonymous_sign_inshas been enabled (this simplifies our demo, but you could let users sign-in instead). - Seed data script which gives us some test data to start with.
Starting Supabase will output a bunch of useful key-values and local URLs (like the Supabase studio URL).
We’re now all set to start the PowerSync Service (alternatively run backend/docker-start.sh):
This ensures that we have a PowerSync Service running with the sync rules specified in backend/sync-rules.yaml, and we’re using the Supabase database as both our source database and our database for the PowerSync Service’s sync bucket storage.
Sync Rules use a SQL-like syntax to tell the PowerSync Service how to handle data and changes from the source database, put them in the service’s bucket storage, and then sync to connecting clients. It’s often the case that the source database’s schema, the sync rules, and your app’s PowerSync schema have very similar shapes.
For this tutorial, we want to sync all thoughts and reactions to all users.
This is the topology we end up with:
How does a Syncosaurus travel? Local-first class.
Ready, Set, Sync!
Finally we get to run our app:
Pick a simulator (press i to open the Expo Go app in your iOS simulator, shift + i to open multiple simulators)
Note: For Android emulators you may need to forward ports so that the emulator can reach local ports.
Opening Supabase studio at http://127.0.0.1:54323/project/default, you can see changes made in your app will sync to Supabase, and changes made in Supabase will reflect in your app.
Oh no, I lost my internet connection!?
With everything working, we can kill the PowerSync Service to simulate an offline scenario between the devices. After the service is down, add some thoughts and reactions to both devices. Your app will keep updating with the changes made locally.
We can then restart the service (docker restart expo-powersync) to see the both apps catch up on everything that has happened in our mock network blip.
Bonus segments
Expo file system persister
The sql.js database adapter uses an in-memory persister by default, restarting your development app means your local data will disappear and have to be synced down again. We can specify a file persister with the help of the Expo File System module so that changes are written and read from disk.
Just slightly alter the SystemProvider.
Raw SQL or use an ORM?
For simplicity this tutorial covered usage with raw SQL queries, however we support two well known DB abstractions for those who prefer type-safety and ease of use. The below examples show how the src/app/index.tsx would differ (assuming appropriate setup).
Do these queries seem trivial?
We tried keeping the queries simple today, but you are only limited by the bounds of SQLite. For example we have spread our two queries for fetching the thoughts and reactions across components, but if we wanted to run a single query in a higher level component and trickle the data down we could have done so with a join query.
As another example, what if we wanted to rate the thought authors:
Which gives us this nice report.
So what have we really won here?
Is this a lot of work for no real benefit? No, because here’s the world you live in now: think about what steps you would take to implement ordering functionality if you had a conventional backend. You would have to implement sorting parameters in your REST endpoint and sort the response on your backend before you could update your API calls to include sort parameters in your client.
With a local database, you can simply add ORDER BY to the query.
SELECT * FROM journey WHERE status = 'THE END'
We covered a lot of powerful features, but we hope the benefits and ease of use of PowerSync shined through.
For more about PowerSync see our documentation. For more info on using PowerSync with Expo see our Expo integration guide. Got questions? You can find our contact information here.


