Supabase with React Native: Mobile App Tutorial

Building native mobile applications with React Native and Supabase enables cross-platform iOS and Android development with shared JavaScript codebase, real-time features, secure authentication, offline capabilities, and seamless backend integration. Unlike traditional native development requiring separate Swift and Kotlin codebases, React Native allows writing once and deploying everywhere while Supabase provides instant backend with PostgreSQL database, authentication, real-time subscriptions, file storage, and serverless functions. This comprehensive guide covers setting up React Native with Expo and Supabase, implementing email and OAuth authentication with AsyncStorage persistence, building CRUD operations with optimistic updates, integrating real-time subscriptions for live data, implementing image uploads with React Native camera integration, adding offline support with AsyncStorage caching, handling push notifications, and deploying to iOS App Store and Google Play Store. React Native with Supabase becomes ideal when building mobile-first applications, targeting both platforms simultaneously, needing backend without infrastructure management, or creating MVPs with rapid development cycles. Before proceeding, understand authentication, database queries, and real-time features.
Project Setup
# Create React Native app with Expo
npx create-expo-app SupabaseNativeApp
cd SupabaseNativeApp
# Install Supabase and dependencies
npm install @supabase/supabase-js
npm install @react-native-async-storage/async-storage
npm install react-native-url-polyfill
npm install expo-secure-store
npm install expo-image-picker
npm install expo-camera
# Project structure
SupabaseNativeApp/
app/
(tabs)/
index.tsx
profile.tsx
_layout.tsx
components/
PostsList.tsx
PostForm.tsx
lib/
supabase.ts
storage.ts
types/
database.types.ts
app.json
package.json
# Configure app.json
{
"expo": {
"name": "SupabaseNativeApp",
"slug": "supabase-native-app",
"version": "1.0.0",
"scheme": "supabasenativeapp",
"platforms": ["ios", "android"],
"ios": {
"bundleIdentifier": "com.yourcompany.supabasenativeapp",
"supportsTablet": true
},
"android": {
"package": "com.yourcompany.supabasenativeapp",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"plugins": [
"expo-secure-store",
[
"expo-image-picker",
{
"photosPermission": "Allow access to select photos"
}
],
[
"expo-camera",
{
"cameraPermission": "Allow access to camera"
}
]
]
}
}Supabase Configuration
// lib/supabase.ts
import 'react-native-url-polyfill/auto'
import { createClient } from '@supabase/supabase-js'
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as SecureStore from 'expo-secure-store'
import { Database } from '../types/database.types'
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!
// Custom storage adapter using SecureStore
const ExpoSecureStoreAdapter = {
getItem: async (key: string) => {
return await SecureStore.getItemAsync(key)
},
setItem: async (key: string, value: string) => {
await SecureStore.setItemAsync(key, value)
},
removeItem: async (key: string) => {
await SecureStore.deleteItemAsync(key)
},
}
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
auth: {
storage: ExpoSecureStoreAdapter,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
})
// .env.local
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
// lib/storage.ts - Offline cache
import AsyncStorage from '@react-native-async-storage/async-storage'
export const cacheData = async (key: string, data: any) => {
try {
await AsyncStorage.setItem(key, JSON.stringify(data))
} catch (error) {
console.error('Error caching data:', error)
}
}
export const getCachedData = async (key: string) => {
try {
const cached = await AsyncStorage.getItem(key)
return cached ? JSON.parse(cached) : null
} catch (error) {
console.error('Error reading cache:', error)
return null
}
}
export const clearCache = async () => {
try {
await AsyncStorage.clear()
} catch (error) {
console.error('Error clearing cache:', error)
}
}Authentication Implementation
// app/(auth)/login.tsx
import { useState } from 'react'
import { View, TextInput, Button, Text, StyleSheet, Alert } from 'react-native'
import { supabase } from '../../lib/supabase'
import { useRouter } from 'expo-router'
export default function LoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
async function signIn() {
setLoading(true)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
Alert.alert('Error', error.message)
} else {
router.replace('/(tabs)')
}
setLoading(false)
}
async function signUp() {
setLoading(true)
const { error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
Alert.alert('Error', error.message)
} else {
Alert.alert('Success', 'Check your email for verification link')
}
setLoading(false)
}
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome</Text>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Button
title={loading ? 'Loading...' : 'Sign In'}
onPress={signIn}
disabled={loading}
/>
<Button
title="Sign Up"
onPress={signUp}
disabled={loading}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
backgroundColor: '#fff',
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
},
input: {
borderWidth: 1,
borderColor: '#ddd',
padding: 12,
marginBottom: 12,
borderRadius: 8,
},
})
// app/_layout.tsx - Auth guard
import { useEffect, useState } from 'react'
import { Slot, useRouter, useSegments } from 'expo-router'
import { supabase } from '../lib/supabase'
import type { Session } from '@supabase/supabase-js'
export default function RootLayout() {
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
const segments = useSegments()
const router = useRouter()
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
setLoading(false)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session)
}
)
return () => subscription.unsubscribe()
}, [])
useEffect(() => {
if (loading) return
const inAuthGroup = segments[0] === '(auth)'
if (!session && !inAuthGroup) {
router.replace('/(auth)/login')
} else if (session && inAuthGroup) {
router.replace('/(tabs)')
}
}, [session, segments, loading])
return <Slot />
}CRUD Operations with Offline Support
// components/PostsList.tsx
import { useState, useEffect } from 'react'
import {
View,
Text,
FlatList,
StyleSheet,
RefreshControl,
Alert,
} from 'react-native'
import { supabase } from '../lib/supabase'
import { cacheData, getCachedData } from '../lib/storage'
import NetInfo from '@react-native-community/netinfo'
interface Post {
id: string
title: string
content: string
created_at: string
}
export default function PostsList() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [isOnline, setIsOnline] = useState(true)
useEffect(() => {
// Check network status
const unsubscribe = NetInfo.addEventListener(state => {
setIsOnline(state.isConnected ?? false)
})
loadPosts()
return () => unsubscribe()
}, [])
async function loadPosts() {
try {
// Try to load from cache first
const cached = await getCachedData('posts')
if (cached) {
setPosts(cached)
}
// Fetch fresh data if online
const netInfo = await NetInfo.fetch()
if (netInfo.isConnected) {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
setPosts(data)
await cacheData('posts', data)
} else {
Alert.alert('Offline', 'Showing cached data')
}
} catch (error) {
Alert.alert('Error', error.message)
} finally {
setLoading(false)
}
}
async function onRefresh() {
setRefreshing(true)
await loadPosts()
setRefreshing(false)
}
async function deletePost(id: string) {
Alert.alert(
'Delete Post',
'Are you sure?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
// Optimistic update
const originalPosts = [...posts]
setPosts(posts.filter(p => p.id !== id))
try {
const { error } = await supabase
.from('posts')
.delete()
.eq('id', id)
if (error) throw error
await cacheData('posts', posts.filter(p => p.id !== id))
} catch (error) {
// Revert on error
setPosts(originalPosts)
Alert.alert('Error', error.message)
}
},
},
]
)
}
return (
<View style={styles.container}>
{!isOnline && (
<View style={styles.offlineBanner}>
<Text style={styles.offlineText}>Offline Mode</Text>
</View>
)}
<FlatList
data={posts}
keyExtractor={item => item.id}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
renderItem={({ item }) => (
<View style={styles.post}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.content}>{item.content}</Text>
<Text style={styles.date}>
{new Date(item.created_at).toLocaleDateString()}
</Text>
</View>
)}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
offlineBanner: {
backgroundColor: '#ff9800',
padding: 10,
alignItems: 'center',
},
offlineText: {
color: '#fff',
fontWeight: 'bold',
},
post: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
title: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
},
content: {
color: '#666',
marginBottom: 8,
},
date: {
fontSize: 12,
color: '#999',
},
})Real-time Subscriptions
// hooks/useRealtimePosts.ts
import { useEffect, useState } from 'react'
import { supabase } from '../lib/supabase'
import type { RealtimePostgresChangesPayload } from '@supabase/supabase-js'
interface Post {
id: string
title: string
content: string
created_at: string
}
export function useRealtimePosts() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// Initial load
loadPosts()
// Subscribe to changes
const channel = supabase
.channel('posts-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'posts',
},
(payload: RealtimePostgresChangesPayload<Post>) => {
handleRealtimeEvent(payload)
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
async function loadPosts() {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (data) setPosts(data)
setLoading(false)
}
function handleRealtimeEvent(
payload: RealtimePostgresChangesPayload<Post>
) {
switch (payload.eventType) {
case 'INSERT':
setPosts(current => [payload.new, ...current])
break
case 'UPDATE':
setPosts(current =>
current.map(post =>
post.id === payload.new.id ? payload.new : post
)
)
break
case 'DELETE':
setPosts(current =>
current.filter(post => post.id !== payload.old.id)
)
break
}
}
return { posts, loading }
}
// Usage in component
import { View, FlatList, Text } from 'react-native'
import { useRealtimePosts } from '../hooks/useRealtimePosts'
export default function RealtimePostsScreen() {
const { posts, loading } = useRealtimePosts()
if (loading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Loading...</Text>
</View>
)
}
return (
<FlatList
data={posts}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<View style={{ padding: 16, borderBottomWidth: 1 }}>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>
{item.title}
</Text>
<Text>{item.content}</Text>
</View>
)}
/>
)
}Image Upload with Camera
// components/ImageUploader.tsx
import { useState } from 'react'
import { View, Button, Image, Alert, StyleSheet } from 'react-native'
import * as ImagePicker from 'expo-image-picker'
import { supabase } from '../lib/supabase'
import { decode } from 'base64-arraybuffer'
export default function ImageUploader() {
const [imageUri, setImageUri] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
async function pickImage() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== 'granted') {
Alert.alert('Permission needed', 'Please grant photo library access')
return
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
})
if (!result.canceled) {
setImageUri(result.assets[0].uri)
}
}
async function takePhoto() {
const { status } = await ImagePicker.requestCameraPermissionsAsync()
if (status !== 'granted') {
Alert.alert('Permission needed', 'Please grant camera access')
return
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
})
if (!result.canceled) {
setImageUri(result.assets[0].uri)
}
}
async function uploadImage() {
if (!imageUri) return
setUploading(true)
try {
// Convert image to blob
const response = await fetch(imageUri)
const blob = await response.blob()
const arrayBuffer = await new Response(blob).arrayBuffer()
const fileName = `${Date.now()}.jpg`
const { data: user } = await supabase.auth.getUser()
// Upload to Supabase Storage
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${user?.user?.id}/${fileName}`, arrayBuffer, {
contentType: 'image/jpeg',
upsert: false,
})
if (error) throw error
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(data.path)
Alert.alert('Success', 'Image uploaded!')
console.log('Public URL:', publicUrl)
// Update user profile with image URL
await supabase
.from('profiles')
.update({ avatar_url: publicUrl })
.eq('id', user?.user?.id)
} catch (error) {
Alert.alert('Error', error.message)
} finally {
setUploading(false)
}
}
return (
<View style={styles.container}>
{imageUri && (
<Image source={{ uri: imageUri }} style={styles.image} />
)}
<View style={styles.buttons}>
<Button title="Pick from Gallery" onPress={pickImage} />
<Button title="Take Photo" onPress={takePhoto} />
{imageUri && (
<Button
title={uploading ? 'Uploading...' : 'Upload'}
onPress={uploadImage}
disabled={uploading}
/>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
padding: 20,
},
image: {
width: '100%',
height: 300,
borderRadius: 8,
marginBottom: 20,
},
buttons: {
gap: 10,
},
})Push Notifications
// Install dependencies
npm install expo-notifications expo-device expo-constants
// lib/notifications.ts
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'
import { Platform } from 'react-native'
import { supabase } from './supabase'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
})
export async function registerForPushNotifications() {
if (!Device.isDevice) {
console.log('Push notifications only work on physical devices')
return null
}
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') {
console.log('Push notification permission denied')
return null
}
const token = (await Notifications.getExpoPushTokenAsync()).data
// Save token to Supabase
const { data: { user } } = await supabase.auth.getUser()
if (user) {
await supabase
.from('push_tokens')
.upsert({
user_id: user.id,
token,
platform: Platform.OS,
})
}
return token
}
// Usage in app
import { useEffect, useRef } from 'react'
import { registerForPushNotifications } from './lib/notifications'
import * as Notifications from 'expo-notifications'
export default function App() {
const notificationListener = useRef<any>()
const responseListener = useRef<any>()
useEffect(() => {
registerForPushNotifications()
// Listen for notifications
notificationListener.current = Notifications.addNotificationReceivedListener(
notification => {
console.log('Notification received:', notification)
}
)
// Handle notification taps
responseListener.current = Notifications.addNotificationResponseReceivedListener(
response => {
console.log('Notification tapped:', response)
}
)
return () => {
Notifications.removeNotificationSubscription(notificationListener.current)
Notifications.removeNotificationSubscription(responseListener.current)
}
}, [])
return (
// Your app
)
}
// Send notification from Edge Function
// supabase/functions/send-notification/index.ts
import { serve } from 'https://deno.land/[email protected]/http/server.ts'
serve(async (req) => {
const { userId, title, body } = await req.json()
// Get user's push token
const { data } = await supabase
.from('push_tokens')
.select('token')
.eq('user_id', userId)
.single()
if (!data) {
return new Response('No token found', { status: 404 })
}
// Send via Expo Push API
const message = {
to: data.token,
sound: 'default',
title,
body,
}
await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
})
return new Response('Notification sent')
})Building and Deployment
# Build for iOS
eas build --platform ios
# Build for Android
eas build --platform android
# Configure EAS (Expo Application Services)
npx eas-cli login
eas build:configure
# eas.json configuration
{
"build": {
"preview": {
"ios": {
"simulator": true
},
"android": {
"buildType": "apk"
}
},
"production": {
"ios": {
"bundleIdentifier": "com.yourcompany.supabasenativeapp"
},
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "[email protected]",
"ascAppId": "1234567890"
},
"android": {
"serviceAccountKeyPath": "./service-account.json",
"track": "production"
}
}
}
}
# Submit to App Store
eas submit --platform ios
# Submit to Google Play
eas submit --platform android
# Test on device
eas build --profile preview --platform ios
eas build --profile preview --platform android
# Over-the-air updates
eas update --branch production --message "Bug fixes"
# Environment variables for production
# Add in Expo dashboard or eas.json
{
"build": {
"production": {
"env": {
"EXPO_PUBLIC_SUPABASE_URL": "https://prod.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "prod-key"
}
}
}
}React Native Best Practices
- Use SecureStore for Tokens: Store auth tokens in expo-secure-store not AsyncStorage for security
- Implement Offline Support: Cache data with AsyncStorage handling network failures gracefully
- Optimize Images: Compress images before upload reducing bandwidth and storage costs
- Handle Permissions: Request camera and photo library permissions before accessing features
- Use Optimistic Updates: Update UI immediately reverting on error for better UX
- Test on Real Devices: Push notifications and camera only work on physical devices
- Monitor Performance: Profile with React DevTools and apply optimization techniques
Common Issues
- Auth Not Persisting: Ensure using SecureStore adapter in Supabase client configuration
- Image Upload Fails: Check Storage bucket permissions and file size limits
- Push Notifications Not Working: Only work on physical devices, verify Expo push token registration
- Real-time Not Updating: Check Supabase RLS policies allow subscriptions and channel is subscribed
Next Steps
- Compare Frameworks: Explore Flutter alternative for native performance
- Add Real-time Features: Implement live subscriptions
- Enhance Security: Apply security best practices
- Optimize Performance: Implement caching strategies
Conclusion
Building native mobile applications with React Native and Supabase enables cross-platform development with shared codebase targeting iOS and Android simultaneously while maintaining native performance and user experience. By setting up React Native with Expo and Supabase client configured with SecureStore for secure token storage, implementing email and OAuth authentication with session persistence, building CRUD operations with optimistic updates and offline caching, integrating real-time subscriptions providing live data synchronization, implementing image uploads with camera integration and Storage buckets, adding push notifications with Expo notifications API, handling offline scenarios with AsyncStorage fallbacks, and deploying to App Store and Play Store with EAS, you build production-ready mobile applications. React Native advantages include code sharing between platforms reducing development time, large community with extensive libraries, hot reloading accelerating development, and native performance through bridge architecture while Supabase provides instant backend with PostgreSQL database, real-time subscriptions, authentication, file storage, and serverless functions. Always use SecureStore for auth tokens never AsyncStorage, implement offline support caching critical data, optimize images before upload reducing bandwidth, handle permissions gracefully requesting before access, use optimistic updates improving perceived performance, test on real devices verifying features work correctly, and monitor performance profiling bottlenecks. React Native with Supabase becomes ideal for mobile-first applications, cross-platform requirements, rapid MVP development, or teams with JavaScript expertise building native experiences. Continue with real-time features, security, and React patterns.
$ share --platform
$ cat /comments/ (0)
$ cat /comments/
// No comments found. Be the first!


