React Native Backend for Expo Apps: Auth, Data and Push
A good React Native backend gives you a database, sign-in, file storage and push without making you run servers, and it has to cope with mobile details: sessions that survive app restarts, OAuth that returns through a deep link, and native push tokens. stitchdb-js runs in React Native, so an Expo app can use the same client a web app uses, with one change: you hand it AsyncStorage for the session. This guide sets up an Expo app end to end.
#The pieces
| Part | Package or API | Notes |
|---|---|---|
| Client | stitchdb-js | Works in browsers, Node.js 18+, Deno and React Native |
| Session storage | @react-native-async-storage/async-storage | Passed as auth.storage |
| OAuth and hosted sign-in | expo-web-browser, expo-linking | Returns through your app's scheme |
| Data access | REST API + Row Level Security | No API server to write |
| Files | Storage buckets + policies | Upload from the device |
| Push | Native APNs / FCM token | Registered with stitchdb.push |
| Purchases | Store receipts checked server-side | Pro plan and up |
#1. Install
npx create-expo-app my-app
cd my-app
npm install stitchdb-js
npx expo install @react-native-async-storage/async-storage expo-web-browser expo-linkingAdd your project URL and anon key to .env. Expo inlines variables with the EXPO_PUBLIC_ prefix into the app bundle:
EXPO_PUBLIC_STITCHDB_URL=https://<project-ref>.p.stitchdb.com
EXPO_PUBLIC_STITCHDB_ANON_KEY=<anon-key>Everything in the bundle is readable by anyone who downloads the app. That is fine for the anon key. It is not fine for the service_role key, which bypasses Row Level Security; it belongs only on servers.
#2. Create the client with AsyncStorage
// lib/stitchdb.js
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from 'stitchdb-js'
export const stitchdb = createClient(
process.env.EXPO_PUBLIC_STITCHDB_URL,
process.env.EXPO_PUBLIC_STITCHDB_ANON_KEY,
{
auth: {
storage: AsyncStorage,
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: false,
flowType: 'pkce',
},
},
)Why these options:
storage: AsyncStoragekeeps the session across app restarts. On the web the default islocalStorage, which React Native does not have.detectSessionInUrl: falsebecause there is no browser address bar to read a session from.flowType: 'pkce'so OAuth and the hosted sign-in page return a one-time code instead of tokens in a URL.
If you want the session in the iOS keychain or Android keystore instead, you can pass any object with getItem, setItem and removeItem. Expo's SecureStore docs note that some iOS versions rejected values above roughly 2,048 bytes, and sessions can be larger than that, so test it or split the value if you go that route.
#3. Tables that protect themselves
create table public.todos (
id bigint generated always as identity primary key,
user_id uuid not null default auth.uid() references auth.users on delete cascade,
task text not null,
is_complete boolean not null default false,
created_at timestamptz not null default now()
);
alter table public.todos enable row level security;
create policy "Users manage their own todos" on public.todos
for all to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());Postgres creates tables with RLS off, and a table without RLS is reachable with the anon key. RLS Guard, on by default for new projects, turns it on for new tables in public; enable it in the same script anyway so the migration is correct everywhere. Read more in the Row Level Security docs.
#4. Sign-in
Password sign-in needs no browser:
await stitchdb.auth.signInWithPassword({ email: '[email protected]', password: 'a-strong-password' })For OAuth or the hosted sign-in page, open the system browser and come back through a deep link. Set a scheme in app.json (for example "scheme": "myapp"), and add myapp://auth-callback to the project's redirect URLs.
import * as WebBrowser from 'expo-web-browser'
import * as Linking from 'expo-linking'
import { stitchdb } from './lib/stitchdb'
export async function signInWithHostedPage() {
const redirectTo = 'myapp://auth-callback'
const { data, error } = await stitchdb.auth.signInWithHostedPage({ redirectTo, skipBrowserRedirect: true })
if (error) throw error
const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo)
if (result.type !== 'success') return
const { queryParams } = Linking.parse(result.url)
await stitchdb.auth.exchangeCodeForSession(String(queryParams.code))
}skipBrowserRedirect: true returns the URL instead of navigating, which is what you want inside a native app. The hosted sign-in page covers password, sign-up, e-mail links and codes, password reset, two-step codes and your OAuth providers. A PKCE code works once, for five minutes, and only with the verifier the client kept, so an intercepted redirect is not enough to take over the account.
The same pattern works for a single provider with signInWithOAuth. If you use a native Google or Apple sign-in library instead, pass its ID token directly:
// A Google or Apple ID token from a native sign-in library
await stitchdb.auth.signInWithIdToken({ provider: 'google', token: idToken })Listen for changes in your root component to switch between the signed-in and signed-out navigators:
stitchdb.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') console.log('Hello', session.user.email)
if (event === 'SIGNED_OUT') console.log('Bye')
})#5. Read and write data
const { data, error } = await stitchdb
.from('todos')
.select('id, task, is_complete')
.eq('is_complete', false)
.order('created_at', { ascending: false })
.range(0, 9)
await stitchdb.from('todos').insert({ task: 'New task' })No user filter: the policy adds it. For live updates, publish the table and subscribe:
stitchdb
.channel('todos')
.on('postgres_changes', { event: '*', schema: 'public', table: 'todos' }, (payload) => {
console.log(payload.eventType, payload.new)
})
.subscribe()Remove the channel with stitchdb.removeChannel(channel) when the screen unmounts, or subscriptions pile up as users move around the app.
#6. Upload a photo
Image pickers give you a local file URI. Read it into an ArrayBuffer and upload it into the user's folder:
const response = await fetch(asset.uri)
const body = await response.arrayBuffer()
const { data, error } = await stitchdb.storage
.from('avatars')
.upload(`${user.id}/avatar.jpg`, body, { contentType: 'image/jpeg', upsert: true })Pair it with a policy on storage.objects that checks (storage.foldername(name))[1] = auth.uid()::text. For lists of thumbnails, request resized images through getPublicUrl(path, { transform: { width: 200 } }) instead of the original; phones on mobile data will thank you.
#7. Push notifications
StitchDB sends through APNs and Firebase Cloud Messaging directly, so it needs the native device token, not an Expo push token. With expo-notifications, that is getDevicePushTokenAsync():
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
const { data: token } = await Notifications.getDevicePushTokenAsync()
await stitchdb.push.register({ platform: Platform.OS === 'ios' ? 'ios' : 'android', token })Register after sign-in, again when the token changes, and unregister on sign-out. Upload your APNs key and Firebase service account once in Studio › Integrations › Push. Then send from SQL, a trigger or your server:
select push.notify('<user-id>', 'Order shipped', 'Your order #1042 is on its way', '{"order": 1042}');Every message also lands in the user's in-app inbox, which you read with stitchdb.push.inbox({ limit: 20 }). Details are in Push notifications.
#8. Purchases and paid features
Whatever purchase library you use, send the result to be verified with the store before unlocking anything:
// Android: in onPurchasesUpdated, for each purchase in state PURCHASED
const { entitlements } = await stitchdb.purchases.sync({
store: 'google',
purchase_token: purchase.purchaseToken,
product_id: purchase.products[0], // needed for one-time products
})StitchDB acknowledges Google purchases for you; Google refunds unacknowledged ones after three days. Protect premium data with purchases.has_entitlement(auth.uid(), 'pro') in a policy, not just a flag in the UI. See Verify in-app purchases server-side.
#9. Server code
When you need a secret (an e-mail API key, a payment provider), write an Edge Function and call it from the app:
const { data, error } = await stitchdb.functions.invoke('hello-world', {
body: { name: 'Ada' },
})The SDK sends the signed-in user's token, so the function knows who is calling. See the Edge Functions guide.
#Trade-offs
- Data lives in EU data centers (Germany and Finland). Good for GDPR and European users; slower round trips for users in Asia or the Americas.
- Offline support is not automatic for every query. Offline sync exists for chosen tables; on React Native you pass your own store with
get,set,delete,entries(prefix)andclear. - The docs list React Native as a supported runtime for the core client, but the push, purchase and crash examples are written for web, native iOS, Android and Flutter. Expect to adapt them (as in steps 7 and 8) rather than copy them.
#FAQ
#Does stitchdb-js work with Expo Go?
The client is plain JavaScript and works in Expo Go. Native push tokens and some purchase libraries need a development build.
#Can I use Firebase for push and StitchDB for data?
Yes. StitchDB sends Android pushes through Firebase Cloud Messaging with your own service account, so you keep FCM and use one inbox and one set of device records in Postgres.
#Where is the session stored?
Wherever you pass in auth.storage; AsyncStorage in this guide. The SDK refreshes the access token before it expires.
#Is the anon key in my app bundle a problem?
No, if Row Level Security is on for every table. The anon key gets the anon role, and policies decide what that role can do.
Build your Expo app on Postgres: create a free StitchDB project.