Supabase para Mobile: Flutter/React Native + Supabase
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_flutter
Configuració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-js
Configuració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
redirectTocon 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.