Supabase para Mobile: Flutter/React Native + Supabase
Kevin Dávila
Flutter o React Native, da igual: tarde o temprano todos terminan haciendo la misma pregunta. ¿Y el backend? Ahí empieza lo divertido: auth, storage, realtime, y otra semana perdida antes de escribir la primera pantalla.
Supabase responde esa pregunta con un solo SDK que funciona igual en web y mobile. En este post vas a integrarlo en Flutter y React Native: auth nativo con biometría, storage para las fotos, realtime para el chat y modo offline.
Por qué Supabase para mobile
Supabase funciona perfecto para apps móviles porque:
Mismo SDK para web y mobile (supabase-js)
Auth nativo con biometría y deep links
Storage para fotos y archivos
Realtime para chat y notificaciones
Offline-first con el cliente adecuado
Flutter + Supabase
Setup
// Crear proyecto Flutter
flutter create my_app
cd my_app
// Agregar dependencias
flutter pub add supabase_flutterConfiguración
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'https://tu-proyecto.supabase.co',
anonKey: 'tu-anon-key',
);
runApp(MyApp());
}
// Acceso global al cliente
final supabase = Supabase.instance.client;Auth en Flutter
// lib/services/auth_service.dart
import 'package:supabase_flutter/supabase_flutter.dart';
class AuthService {
final _supabase = Supabase.instance.client;
// Email/Password
Future<AuthResponse> signIn(String email, String password) async {
return await _supabase.auth.signInWithPassword(
email: email,
password: password,
);
}
Future<AuthResponse> signUp(String email, String password) async {
return await _supabase.auth.signUp(
email: email,
password: password,
);
}
// Google OAuth
Future<void> signInWithGoogle() async {
await _supabase.auth.signInWithOAuth(
OAuthProvider.google,
redirectTo: 'io.supabase.myapp://login-callback/',
);
}
// Logout
Future<void> signOut() async {
await _supabase.auth.signOut();
}
// Usuario actual
User? get currentUser => _supabase.auth.currentUser;
// Escuchar cambios de auth
Stream<AuthState> get authStateChanges =>
_supabase.auth.onAuthStateChange;
}Deep Links para OAuth
Para que OAuth funcione en mobile, configura deep links:
Android (android/app/src/main/AndroidManifest.xml):
<manifest>
<application>
<activity>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="io.supabase.myapp"
android:host="login-callback" />
</intent-filter>
</activity>
</application>
</manifest>iOS (ios/Runner/Info.plist):
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>io.supabase.myapp</string>
</array>
</dict>
</array>Queries en Flutter
// lib/services/task_service.dart
import 'package:supabase_flutter/supabase_flutter.dart';
class TaskService {
final _supabase = Supabase.instance.client;
Future<List<Map<String, dynamic>>> getTasks() async {
final user = _supabase.auth.currentUser;
if (user == null) throw Exception('Not authenticated');
final data = await _supabase
.from('tasks')
.select()
.eq('user_id', user.id)
.order('created_at', ascending: false);
return List<Map<String, dynamic>>.from(data);
}
Future<void> createTask(String title) async {
final user = _supabase.auth.currentUser;
if (user == null) throw Exception('Not authenticated');
await _supabase.from('tasks').insert({
'user_id': user.id,
'title': title,
});
}
Future<void> toggleTask(String taskId, bool completed) async {
await _supabase
.from('tasks')
.update({'completed': completed})
.eq('id', taskId);
}
}Realtime en Flutter
// lib/services/realtime_service.dart
import 'package:supabase_flutter/supabase_flutter.dart';
class RealtimeService {
final _supabase = Supabase.instance.client;
RealtimeChannel? _channel;
void listenToTasks(void Function(Map<String, dynamic>) onInsert) {
_channel = _supabase
.channel('tasks')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'tasks',
callback: (payload) {
onInsert(payload.newRecord);
},
)
.subscribe();
}
void stopListening() {
_channel?.unsubscribe();
}
}React Native + Supabase
Setup
// Crear proyecto React Native
npx react-native init MyApp
cd MyApp
// Agregar dependencias
npm install @supabase/supabase-jsConfiguración
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
import AsyncStorage from '@react-native-async-storage/async-storage'
const supabaseUrl = 'https://tu-proyecto.supabase.co'
const supabaseKey = 'tu-anon-key'
export const supabase = createClient(supabaseUrl, supabaseKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
})Auth en React Native
// src/services/auth.ts
import { supabase } from '../lib/supabase'
export async function signIn(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) throw error
return data
}
export async function signUp(email: string, password: string) {
const { data, error } = await supabase.auth.signUp({
email,
password,
})
if (error) throw error
return data
}
export async function signInWithGoogle() {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'myapp://login-callback',
},
})
if (error) throw error
return data
}
export async function signOut() {
const { error } = await supabase.auth.signOut()
if (error) throw error
}Storage en React Native
// src/services/storage.ts
import { supabase } from '../lib/supabase'
import * as FileSystem from 'expo-file-system'
export async function uploadImage(uri: string, userId: string) {
// Leer el archivo
const response = await fetch(uri)
const blob = await response.blob()
const filePath = `${userId}/${Date.now()}.jpg`
const { data, error } = await supabase.storage
.from('avatars')
.upload(filePath, blob, {
contentType: 'image/jpeg',
})
if (error) throw error
// Obtener URL pública
const { data: urlData } = supabase.storage
.from('avatars')
.getPublicUrl(filePath)
return urlData.publicUrl
}Realtime en React Native
// src/services/realtime.ts
import { supabase } from '../lib/supabase'
export function listenToMessages(
roomId: string,
onMessage: (msg: any) => void
) {
const channel = supabase
.channel(`room-${roomId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`,
},
(payload) => {
onMessage(payload.new)
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}Offline-first
Para apps que necesitan funcionar sin conexión:
Con Flutter
// lib/services/offline_service.dart
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class OfflineService {
final _supabase = Supabase.instance.client;
final _pendingActions = <Future Function()>[];
Future<void> executeWithOfflineSupport(
Future Function() action,
) async {
final connectivity = await Connectivity().checkConnectivity();
if (connectivity == ConnectivityResult.none) {
_pendingActions.add(action);
return;
}
await action();
}
Future<void> syncPendingActions() async {
for (final action in _pendingActions) {
await action();
}
_pendingActions.clear();
}
}Con React Native
// src/services/offline.ts
import NetInfo from '@react-native-community/netinfo'
import { supabase } from '../lib/supabase'
class OfflineManager {
private pendingOps: Array<() => Promise<void>> = []
private isOnline = true
constructor() {
NetInfo.addEventListener(state => {
const wasOffline = !this.isOnline
this.isOnline = state.isConnected ?? false
if (wasOffline && this.isOnline) {
this.syncPending()
}
})
}
async execute(op: () => Promise<void>) {
if (this.isOnline) {
await op()
} else {
this.pendingOps.push(op)
}
}
private async syncPending() {
for (const op of this.pendingOps) {
await op()
}
this.pendingOps = []
}
}
export const offlineManager = new OfflineManager()Push Notifications
Con Supabase Edge Functions
// supabase/functions/send-notification/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
serve(async (req) => {
const { userId, title, body } = await req.json()
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
// Obtener token de push del usuario
const { data: profile } = await supabase
.from('profiles')
.select('push_token')
.eq('id', userId)
.single()
if (!profile?.push_token) {
return new Response('No push token', { status: 400 })
}
// Enviar notificación (usando Firebase Cloud Messaging, por ejemplo)
// ...
return new Response(JSON.stringify({ sent: true }))
})Errores comunes
"Invalid redirect URL"
Configura el deep link en el dashboard de Supabase
Agrega la URL en Authentication > URL Configuration
"Session not persisting"
En React Native, asegúrate de usar AsyncStorage
En Flutter, Supabase_flutter maneja esto automáticamente
"OAuth not working on mobile"
Configura deep links correctamente
Usa redirectTo con tu esquema custom
Qué sigue
Esta serie cubrió todo lo que necesitas para dominar Supabase. Desde los fundamentos hasta producción y mobile. Ahora a construir.
Si te fue útil, comparte la serie y visita codeabien.com para más contenido sobre desarrollo web y mobile.