Saltar al contenido principal

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é ofrecePara qué sirve
Base de datos PostgresTablas, relaciones, SQL, índices: una base de datos completa, no un almacén simplificado.
API REST automáticaCada tabla se expone por HTTP sin escribir un servidor: la genera PostgREST a partir del esquema.
AutenticaciónRegistro, 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.
StorageAlmacenamiento de archivos (imágenes, PDF) con las mismas reglas de acceso.
RealtimeNotificaciones en vivo cuando cambian los datos.
Edge FunctionsFunciones 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.

El plan gratuito basta para el curso

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 importa supabase_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.

Arquitectura de una app Flutter con Supabase: la página llama al servicio, el servicio usa el SDK y el SDK habla con la API REST y Postgres
Por qué una capa de servicio

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.

Panel de Supabase en Project Settings, API Keys, con la llave publishable resaltada

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).

Panel de Supabase en Integrations, Data API, con la API URL resaltada

Publishable y secret: cuál va en la app

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:

env.json
{
"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:

.gitignore
env.json

Ejecuta la app indicándole el archivo:

flutter run --dart-define-from-file=env.json
En VS Code

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.

Un archivo oculto no es una caja fuerte

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:

lib/main.dart
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 porque main es async y usa plugins antes de runApp.
  • Supabase.initialize es asíncrono: abre el cliente y recupera la sesión guardada. Por eso se espera con await.
  • Desde ese momento, Supabase.instance.client está disponible en toda la app.
Versiones anteriores del SDK

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:

SQL Editor
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);
  • id lo genera Postgres (generated always as identity): la app nunca lo envía al crear.
  • enable row level security activa 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 demo abren 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.

lib/models/contacto.dart
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.

lib/services/contacto_service.dart
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.

El CRUD de la agenda: cada consulta del servicio enlazada con la petición HTTP a PostgREST y el SQL equivalente
OperaciónMétodo del SDKPetición HTTPSQL
Listarselect().order(...)GET /contactos?select=*&order=nombre.ascSELECT … ORDER BY nombre
Detalleselect().eq(...).single()GET /contactos?id=eq.7 (un objeto)SELECT … WHERE id = 7
Crearinsert(datos)POST /contactos + cuerpo JSONINSERT INTO …
Editarupdate(datos).eq(...)PATCH /contactos?id=eq.7 + cuerpoUPDATE … WHERE id = 7
Borrardelete().eq(...)DELETE /contactos?id=eq.7DELETE … WHERE id = 7
update() y delete() siempre con su filtro

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:

EstadoCuándoQué se dibuja
CargandoLa petición salió y no ha vueltoUn indicador de carga
ErrorLa petición fallóUn mensaje y un botón para reintentar
DatosLa petición volvió bienLa 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:

Estados de una petición asíncrona en Flutter: cargando, error y datos, según lo que responda Supabase

El código completo de la página, con la salvaguarda mounted que se explica más abajo:

lib/pages/contactos_page.dart
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:

  • _cargando arranca en true. Así el primer build() 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.
  • finally apaga 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. PostgrestException es 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 un message. Si no hay red, la petición ni sale y el SDK lanza otra excepción: la atrapa el catch genérico.
  • if (!mounted) return; después de cada await. Si el usuario salió de la pantalla mientras esperaba, el State ya no existe, y llamar a setState lanzaría un error. Es la misma regla de oro de la navegación con context.mounted.
FutureBuilder: la alternativa

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íntomaCausa probableQué revisar
La lista llega vacía aunque hay filasRLS activado y sin política de selectPanel: Authentication → Policies de la tabla
new row violates row-level security policy al crearFalta la política de insertCrear la política de insert
Editar o borrar «no hace nada» y no da errorFalta la política de update/delete, o el filtro no coincidePolíticas y .eq(...)
Could not find the table 'public.contactos' in the schema cacheNombre de tabla mal escrito en from(), o la tabla no existeOrtografía en from('contactos')
Invalid API key o 401URL o llave equivocadas, o env.json no se pasó--dart-define-from-file=env.json
SocketException / ClientExceptionSin conexión, o URL mal escritaInternet y que la URL no lleve /rest/v1/
PGRST116 al usar single()La consulta devolvió 0 o más de 1 filaEl filtro por id; o usa maybeSingle()
La app se queda en el indicador de carga para siempreFalta el finally que apaga _cargandoLa estructura try / catch / finally

Cuestionario de Autoevaluación​

Cargando cuestionario...