$ cat /posts/supabase-with-react-native-mobile-app-tutorial.md
[tags]Supabase

Supabase with React Native: Mobile App Tutorial

drwxr-xr-x2026-01-265 min0 views
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

bashsetup.sh
# 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

typescriptsupabase_config.ts
// 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

typescriptauthentication.tsx
// 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

typescriptposts_list.tsx
// 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

typescriptrealtime_subscriptions.ts
// 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

typescriptimage_uploader.tsx
// 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

typescriptpush_notifications.ts
// 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

bashdeployment.sh
# 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
Pro Tip: Use Expo EAS for simplified build and deployment process. Test thoroughly on both iOS and Android as behavior differs. Implement offline-first architecture with AsyncStorage caching. Compare with Flutter implementation for native performance.

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

  1. Compare Frameworks: Explore Flutter alternative for native performance
  2. Add Real-time Features: Implement live subscriptions
  3. Enhance Security: Apply security best practices
  4. 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.

$ cat /comments/ (0)

new_comment.sh

// Email hidden from public

>_

$ cat /comments/

// No comments found. Be the first!

[session] guest@{codershandbook}[timestamp] 2026

Navigation

Categories

Connect

Subscribe

// 2026 {Coders Handbook}. EOF.