Persistencia con Supabase
Hasta ahora los contactos de la agenda viven en una lista en memoria: al cerrar la app, desaparecen. Para que sobrevivan hace falta una base de datos, y para que varios dispositivos vean lo mismo, esa base de datos tiene que estar en un servidor. Construir ese servidor (API, seguridad, despliegue) es mucho trabajo; Supabase nos lo entrega hecho.
En esta sesión conectaremos la agenda con Supabase, cubriendo:
- Qué es Supabase y qué ofrece.
- La arquitectura de una app Flutter con Supabase: la capa de servicio como frontera entre la interfaz y la base de datos.
- Instalar el SDK, obtener las llaves del proyecto y guardarlas en variables de entorno.
- El CRUD por PostgREST: listar, ver el detalle, crear, editar y borrar.
- Los estados de la interfaz frente a una operación asíncrona: cargando y error.
1. ¿Qué es Supabase?
Supabase es una plataforma de backend como servicio (BaaS) de código abierto, construida sobre Postgres. Con un solo proyecto obtienes una base de datos relacional real y, alrededor de ella, el resto de piezas que una app necesita. Se suele presentar como la alternativa abierta a Firebase, con una diferencia de fondo: aquí los datos viven en tablas SQL y no en documentos.
| Qué ofrece | Para qué sirve |
|---|---|
| Base de datos Postgres | Tablas, relaciones, SQL, índices: una base de datos completa, no un almacén simplificado. |
| API REST automática | Cada tabla se expone por HTTP sin escribir un servidor: la genera PostgREST a partir del esquema. |
| Autenticación | Registro, inicio de sesión (correo, Google, GitHub…) y sesiones, integrados con la base de datos. |
| Row Level Security (RLS) | Políticas en Postgres que deciden qué filas puede leer o modificar cada usuario. |
| Storage | Almacenamiento de archivos (imágenes, PDF) con las mismas reglas de acceso. |
| Realtime | Notificaciones en vivo cuando cambian los datos. |
| Edge Functions | Funciones de servidor para la lógica que no debe vivir en la app. |
Hoy usaremos solo las dos primeras: la base de datos y su API REST. Autenticación, Storage y Realtime se apoyan en el mismo SDK, así que lo que aprendas aquí sirve para todo lo demás.
Crear una cuenta y un proyecto en supabase.com no requiere tarjeta. Un proyecto del plan gratuito se pausa tras una semana sin actividad; se reactiva desde el panel y no pierde datos.
2. Arquitectura de una app Flutter con Supabase
La pregunta de diseño es dónde va el código que habla con Supabase. La respuesta es la misma separación de capas de las semanas anteriores:
- La interfaz (la página, los widgets) dibuja y atiende al usuario. No sabe qué base de datos hay detrás.
- La capa de servicio (
ContactoService) es una clase de Dart con un método por operación. Es la frontera: la única que importasupabase_flutter. - El SDK (
supabase_flutter) arma las peticiones HTTP, y la API REST de Supabase las ejecuta contra Postgres.
Recorre las cinco piezas y sigue una petición de ida y de vuelta: listar los contactos.
Si la página llamara directamente a Supabase.instance.client, cambiar el nombre de una tabla o probar la pantalla con datos falsos obligaría a editar widgets. Con el servicio en medio, la interfaz depende de un contrato (listar(), crear(), borrar()) y no de una tecnología. Es el mismo principio por el que la lista de la semana 12 vivía en AgendaPage y no dentro de cada tarjeta.
3. Instalar Supabase en la app y configurar las credenciales
3.1. Instalar el SDK
El paquete oficial es supabase_flutter. Desde la carpeta del proyecto:
flutter pub add supabase_flutter
Esto agrega la dependencia a pubspec.yaml y descarga los paquetes.
3.2. Obtener las credenciales del proyecto
El SDK necesita saber a qué proyecto conectarse y con qué llave. Son dos datos que están en el panel de Supabase.
La llave (publishable key). En el panel, abre tu proyecto y ve a Project Settings → API Keys. En la sección Publishable key está la llave default, que empieza por sb_publishable_. Cópiala con el botón de la derecha.

La URL del proyecto. Está en Integrations → Data API, en el campo API URL. Tiene la forma https://<id-del-proyecto>.supabase.co/rest/v1/. Para el SDK usarás solo la base, sin /rest/v1/ (el SDK agrega esa ruta por su cuenta).

Los proyectos nuevos tienen dos tipos de llave:
- Publishable (
sb_publishable_…): pública por diseño, va en la app. Por sí sola no da acceso a nada: lo que puede hacer lo deciden las políticas RLS de cada tabla. - Secret (
sb_secret_…): se salta RLS y tiene acceso total. Solo se usa en servidores. Nunca va en una app móvil ni en un repositorio.
En proyectos y tutoriales anteriores la llave pública se llamaba anon key (un texto largo que empieza por eyJ…). Es la misma idea con otro nombre; sigue funcionando mientras tu proyecto la tenga habilitada.
3.3. Guardar las credenciales fuera del código
No escribas la URL y la llave directamente en main.dart: terminarían en el repositorio y cambiar de proyecto (desarrollo, pruebas) obligaría a editar código. Flutter permite pasarlas al compilar con --dart-define, sin paquetes extra.
Crea un archivo env.json en la raíz del proyecto:
{
"SUPABASE_URL": "https://tu-proyecto.supabase.co",
"SUPABASE_PUBLISHABLE_KEY": "sb_publishable_xxxxxxxxxxxxxxxx"
}
Agrégalo a .gitignore para que no se suba al repositorio, y deja un env.example.json con valores de ejemplo para tus compañeros:
env.json
Ejecuta la app indicándole el archivo:
flutter run --dart-define-from-file=env.json
Para no escribir el parámetro cada vez, agrégalo a .vscode/launch.json en "args": ["--dart-define-from-file=env.json"]. En Android Studio, va en Edit Configurations → Additional run args.
Los valores de --dart-define quedan dentro de la app compilada, y cualquiera con paciencia puede extraerlos. Eso es aceptable con la llave publishable porque está pensada para ser pública; por eso la seguridad real de los datos vive en RLS (sección 4), no en esconder la llave.
3.4. Inicializar el SDK
En Dart, una constante con String.fromEnvironment lee el valor que se pasó con --dart-define. Se inicializa Supabase una sola vez, antes de runApp:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'pages/contactos_page.dart';
const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
const supabaseKey = String.fromEnvironment('SUPABASE_PUBLISHABLE_KEY');
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(url: supabaseUrl, publishableKey: supabaseKey);
runApp(const MaterialApp(home: ContactosPage()));
}
WidgetsFlutterBinding.ensureInitialized()es necesario porquemainesasyncy usa plugins antes derunApp.Supabase.initializees asíncrono: abre el cliente y recupera la sesión guardada. Por eso se espera conawait.- Desde ese momento,
Supabase.instance.clientestá disponible en toda la app.
En versiones de supabase_flutter anteriores a la 2.13 el parámetro se llamaba anonKey. Si el pubspec.yaml de un proyecto viejo lo usa, se escribe anonKey: supabaseKey.
4. La tabla y sus políticas de seguridad
Antes del código, necesitamos la tabla. En el panel abre el SQL Editor y ejecuta:
create table public.contactos (
id bigint generated always as identity primary key,
nombre text not null,
email text not null,
created_at timestamptz not null default now()
);
-- RLS: sin políticas, la API no deja leer ni escribir nada
alter table public.contactos enable row level security;
-- Solo para el ejercicio de clase: acceso abierto con la llave publishable
create policy "demo: leer" on public.contactos for select to anon using (true);
create policy "demo: crear" on public.contactos for insert to anon with check (true);
create policy "demo: editar" on public.contactos for update to anon using (true) with check (true);
create policy "demo: borrar" on public.contactos for delete to anon using (true);
idlo genera Postgres (generated always as identity): la app nunca lo envía al crear.enable row level securityactiva RLS. Con RLS activado y sin políticas, la API responde con listas vacías y rechaza las escrituras. Es la configuración segura por defecto.- Las cuatro políticas
demoabren la tabla a cualquiera que tenga la llave. Sirven para este ejercicio; en una app real se restringen a usuarios autenticados (to authenticated using (auth.uid() = user_id)), tema que se construye sobre la autenticación de Supabase.
5. La capa de servicio
Primero el modelo: una clase que representa una fila y sabe convertirse desde y hacia el Map que maneja el SDK.
class Contacto {
final int? id;
final String nombre;
final String email;
const Contacto({this.id, required this.nombre, required this.email});
factory Contacto.fromMap(Map<String, dynamic> m) => Contacto(
id: m['id'] as int,
nombre: m['nombre'] as String,
email: m['email'] as String,
);
// Sin id: lo genera Postgres al insertar.
Map<String, dynamic> toMap() => {'nombre': nombre, 'email': email};
}
Luego el servicio: un método por operación, y la interfaz solo ve Contacto, nunca Map ni Supabase.
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/contacto.dart';
class ContactoService {
final _db = Supabase.instance.client;
static const _tabla = 'contactos';
Future<List<Contacto>> listar() async {
final filas = await _db.from(_tabla).select().order('nombre');
return filas.map(Contacto.fromMap).toList();
}
Future<Contacto> obtener(int id) async {
final fila = await _db.from(_tabla).select().eq('id', id).single();
return Contacto.fromMap(fila);
}
Future<void> crear(Contacto c) => _db.from(_tabla).insert(c.toMap());
Future<void> editar(Contacto c) =>
_db.from(_tabla).update(c.toMap()).eq('id', c.id!);
Future<void> borrar(int id) => _db.from(_tabla).delete().eq('id', id);
}
Todos los métodos devuelven un Future: hablar con Supabase es una operación de red, y eso tiene consecuencias para la interfaz (sección 7).
6. CRUD con PostgREST
Cada llamada del servicio se convierte en una petición HTTP a la API REST de Supabase, que PostgREST traduce a SQL. Entender esa traducción te permite depurar: cuando algo falla, el error habla de HTTP y de SQL, no de Dart.
Pasa el mouse por el código o por las peticiones para ver qué parte de la consulta produce qué parte de la petición.
| Operación | Método del SDK | Petición HTTP | SQL |
|---|---|---|---|
| Listar | select().order(...) | GET /contactos?select=*&order=nombre.asc | SELECT … ORDER BY nombre |
| Detalle | select().eq(...).single() | GET /contactos?id=eq.7 (un objeto) | SELECT … WHERE id = 7 |
| Crear | insert(datos) | POST /contactos + cuerpo JSON | INSERT INTO … |
| Editar | update(datos).eq(...) | PATCH /contactos?id=eq.7 + cuerpo | UPDATE … WHERE id = 7 |
| Borrar | delete().eq(...) | DELETE /contactos?id=eq.7 | DELETE … WHERE id = 7 |
Sin .eq(...), un update() o un delete() no tienen filtro y se aplican a todas las filas que la política de seguridad permita. Antes de ejecutar un borrado, comprueba que la línea tenga su .eq('id', id).
7. Estados de la interfaz: cargando y error
Una llamada al servicio no responde al instante: la petición viaja por Internet, Supabase consulta la base de datos y la respuesta vuelve. Mientras tanto, la app no se bloquea: el await suspende solo la función que espera, y la interfaz sigue viva. Pero, ¿qué debe dibujar build() mientras no hay datos? Tres cosas distintas según el momento:
| Estado | Cuándo | Qué se dibuja |
|---|---|---|
| Cargando | La petición salió y no ha vuelto | Un indicador de carga |
| Error | La petición falló | Un mensaje y un botón para reintentar |
| Datos | La petición volvió bien | La lista |
Esos tres estados se guardan en el State de la página con dos variables (_cargando, _error) y los datos. Elige qué responde Supabase (bien, lento, con error de la API o sin conexión) y sigue lo que ocurre, línea por línea:
El código completo de la página, con la salvaguarda mounted que se explica más abajo:
class _ContactosPageState extends State<ContactosPage> {
final _servicio = ContactoService();
bool _cargando = true;
String? _error;
List<Contacto> _contactos = [];
void initState() {
super.initState();
_cargar();
}
Future<void> _cargar() async {
setState(() {
_cargando = true;
_error = null;
});
try {
final filas = await _servicio.listar();
if (!mounted) return;
setState(() => _contactos = filas);
} on PostgrestException catch (e) {
if (!mounted) return;
setState(() => _error = e.message);
} catch (e) {
if (!mounted) return;
setState(() => _error = 'Sin conexión');
} finally {
if (mounted) setState(() => _cargando = false);
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Agenda')),
body: _cuerpo(),
);
}
Widget _cuerpo() {
if (_cargando) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 12),
FilledButton(onPressed: _cargar, child: const Text('Reintentar')),
],
),
);
}
return ListView(
children: [
for (final c in _contactos)
ListTile(title: Text(c.nombre), subtitle: Text(c.email)),
],
);
}
}
Puntos que conviene entender:
_cargandoarranca entrue. Así el primerbuild()ya dibuja el indicador y no una lista vacía que parpadea._cargar()limpia el estado antes de pedir. Por eso el botón Reintentar puede llamar a la misma función: no existe un segundo camino.finallyapaga el indicador siempre, haya salido bien o mal. Sin él, un error dejaría la app cargando para siempre.- Dos
catch, porque hay dos tipos de fallo.PostgrestExceptiones una respuesta de Supabase con error (una tabla que no existe, una política RLS que no deja leer, un dato inválido) y trae unmessage. Si no hay red, la petición ni sale y el SDK lanza otra excepción: la atrapa elcatchgenérico. if (!mounted) return;después de cadaawait. Si el usuario salió de la pantalla mientras esperaba, elStateya no existe, y llamar asetStatelanzaría un error. Es la misma regla de oro de la navegación concontext.mounted.
Flutter trae el widget FutureBuilder, que gestiona estos tres estados por ti a partir de un Future (snapshot.connectionState, snapshot.hasError, snapshot.data). Es cómodo para una consulta de solo lectura. Con setState y variables explícitas, como arriba, controlas mejor los reintentos y las operaciones que modifican datos (crear, editar, borrar), y es lo que usaremos en la agenda.
8. Errores frecuentes
| Síntoma | Causa probable | Qué revisar |
|---|---|---|
| La lista llega vacía aunque hay filas | RLS activado y sin política de select | Panel: Authentication → Policies de la tabla |
new row violates row-level security policy al crear | Falta la política de insert | Crear la política de insert |
| Editar o borrar «no hace nada» y no da error | Falta la política de update/delete, o el filtro no coincide | Políticas y .eq(...) |
Could not find the table 'public.contactos' in the schema cache | Nombre de tabla mal escrito en from(), o la tabla no existe | Ortografía en from('contactos') |
Invalid API key o 401 | URL o llave equivocadas, o env.json no se pasó | --dart-define-from-file=env.json |
SocketException / ClientException | Sin conexión, o URL mal escrita | Internet y que la URL no lleve /rest/v1/ |
PGRST116 al usar single() | La consulta devolvió 0 o más de 1 fila | El filtro por id; o usa maybeSingle() |
| La app se queda en el indicador de carga para siempre | Falta el finally que apaga _cargando | La estructura try / catch / finally |