React Native
Repositories in Expo and React Native apps, with sessions in the device keychain.
import { createClient } from "@supabase/supabase-js";
import {
autoRefreshOnForeground,
createNativeClient,
secureStorage,
} from "better-supabase/client/native";
import * as SecureStore from "expo-secure-store";
import { AppState } from "react-native";
import { betterSupabase } from "./index";
const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL,
process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
{
auth: {
storage: secureStorage(SecureStore),
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
},
);
autoRefreshOnForeground(supabase, AppState);
export const bs = createNativeClient(betterSupabase, supabase);better-supabase/client/native is the browser client
without the cookie code: it never imports @supabase/ssr, so Metro doesn't
bundle it into the app. bs.db, bs.queries, bs.auth and bs.supabase
work as they do in the browser, and the React hooks
take the same bs.
better-supabase init expo writes this file, a server file for
Expo Router and +middleware.ts.
Sessions in the keychain
secureStorage(SecureStore) stores the Supabase session with
expo-secure-store. A session with custom claims is often larger than the
2048 bytes iOS accepts for one value, so it is split into chunks of 1800
characters (chunkSize changes that). Keys are rewritten to the characters
SecureStore allows. Any object with getItemAsync, setItemAsync and
deleteItemAsync works in its place.
Reads and writes of one key run one at a time. A write stores the new
chunks next to the old ones and switches <key>.chunks to them only once
all are stored, so a read never joins chunks of two sessions, and a write
that fails part way keeps the previous session.
Refreshing in the foreground
supabase-js refreshes the access token on a timer, but on React Native it
can't tell when the app goes to the background. Supabase asks apps to call
supabase.auth.startAutoRefresh() when the app becomes active and
stopAutoRefresh() when it leaves. autoRefreshOnForeground(supabase, AppState)
does both: it starts refreshing at once when AppState.currentState is
active, follows every change event after that, and returns a function
that removes the listener and stops refreshing.
import { useEffect } from "react";
import { AppState } from "react-native";
import { autoRefreshOnForeground } from "better-supabase/client/native";
import { supabase } from "@/lib/supabase/native";
export default function RootLayout() {
useEffect(() => autoRefreshOnForeground(supabase, AppState), []);
// ...
}Call it once, either at module level next to createClient as above or in
an effect as here, not both. AppState is typed by its shape, so
better-supabase never imports react-native; any object with currentState
and addEventListener("change", listener) works, including a fake in a unit
test.
Large sessions
largeSecureStorage keeps a session of any size without chunking: it
encrypts the session with AES-GCM, stores the result in MMKV or AsyncStorage
and keeps only the 256-bit key in the keychain. Every write uses a fresh key.
When the key and the data come from different installs (a reinstall kept one
of them), the read returns null, so the user signs in again instead of
seeing an error.
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
import { largeSecureStorage } from "better-supabase/client/native";
const storage = largeSecureStorage({
secureStore: SecureStore,
storage: AsyncStorage,
});It uses the global crypto. On Hermes builds without WebCrypto, pass
crypto from react-native-quick-crypto.
TanStack Query on a device
syncQueryWithApp tells TanStack Query when the app is in the foreground and
when the device is online, so queries refetch when the user returns and
pause without a connection. With supabase, it also calls
autoRefreshOnForeground, so one call covers both.
import NetInfo from "@react-native-community/netinfo";
import { focusManager, onlineManager } from "@tanstack/react-query";
import { syncQueryWithApp } from "better-supabase/client/native";
import { AppState } from "react-native";
useEffect(
() =>
syncQueryWithApp({
focusManager,
onlineManager,
appState: AppState,
netInfo: NetInfo,
supabase,
}),
[],
);persistQueryCache(storage) is a TanStack Query persister over MMKV or
AsyncStorage. A cold start shows the cached rows before the first fetch, and
writes are throttled to one per second. The provider's clearOnUserChange
drops the cache when another user signs in.
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: persistQueryCache(storage) }}>Uploading picked files
React Native can't upload a Blob. uploadFromUri reads a file:// or
content:// URI from expo-image-picker or expo-document-picker into an
ArrayBuffer and uploads it to a typed bucket. The
content type comes from the contentType option, then the file extension,
then the response header. A file that can't be read returns an
invalid_input error.
const picked = await ImagePicker.launchImageLibraryAsync();
const uri = picked.assets?.[0]?.uri;
if (uri) await uploadFromUri(avatars, { userId }, uri, { upsert: true });Sign-in on a device
better-supabase/react/native has hooks for OAuth, deep links and protected
routes. They read the client from <BetterSupabaseProvider>, or take
{ client: supabase }.
import * as Linking from "expo-linking";
import * as WebBrowser from "expo-web-browser";
import { useOAuth } from "better-supabase/react/native";
export default function SignIn() {
const oauth = useOAuth({
browser: WebBrowser,
redirectTo: Linking.createURL("auth/callback"),
});
return (
<Button
title="GitHub"
disabled={oauth.pending}
onPress={() => oauth.signIn("github")}
/>
);
}oauth.signIn opens the provider in an auth session and finishes the
sign-in from the redirect; a dismissed browser is not an error.
oauth.idToken signs in with an ID token from expo-apple-authentication or
Google Sign-In. Add the redirect URL to the Auth redirect allow list.
useAuthDeepLinks(Linking) finishes sign-ins from links that open the app:
magic links, email confirmations and PKCE codes. It handles the initial URL
and every link after it, once each, and returns the last outcome.
handleAuthDeepLink(supabase, url) and signInWithOAuthBrowser in
better-supabase/client/native do the same without React.
import { useRouter, useSegments } from "expo-router";
import {
useAuthDeepLinks,
useProtectedRoute,
} from "better-supabase/react/native";
function Navigation() {
useAuthDeepLinks(Linking);
const status = useProtectedRoute({
segments: useSegments(),
router: useRouter(),
});
if (status === "loading") return <Splash />;
return <Stack />;
}useProtectedRoute sends signed-out users outside the (auth) group to
/sign-in, and signed-in users inside it to /; publicGroup,
signInHref and homeHref change those. <AuthGate fallback signedOut>
renders by auth status for apps without expo-router. With expo-router's
Stack.Protected, pass useAuth().status === "signed-in" as its guard.
Hermes and Temporal
Hermes has no Temporal, and installing a polyfill on globalThis is a side
effect most apps avoid. Pass the namespace to the definition instead:
import { defineSupabase } from "better-supabase";
import { Temporal } from "temporal-polyfill";
import { schema } from "./generated";
export const betterSupabase = defineSupabase(schema, { temporal: Temporal });With codecs: { timestamptz: "instant" }, rows then decode timestamptz
columns to that namespace's Temporal.Instant and timestamp columns to its
Temporal.PlainDateTime; date columns stay strings. Every helper that reads
the time uses the namespace too. The generated Valibot and Zod schemas
check Temporal values by their tag, so they load without a global. See
Temporal for the types each column gets.
Web and native in one app
Metro picks file.native.ts on iOS and Android and file.ts on the web. Keep
anything that only works on a device (SecureStore, PowerSync) in .native
files or in modules only they import, so the web bundle never loads it. The
Expo example
renders a list from a server loader on the web and from the PowerSync
database on the device, with one list definition.
Offline reads
To read while offline, sync the tables to the device with PowerSync and run the same repositories on its SQLite database. See PowerSync for the executor and Offline-first for uploading local changes.
Last updated on