Supabase è la principale alternativa open source a Firebase: un backend completo costruito su PostgreSQL, con autenticazione, API REST generate automaticamente, sottoscrizioni realtime, storage di file ed Edge Function. Per chi sviluppa in Flutter è una scelta interessante perché il pacchetto ufficiale supabase_flutter copre tutte le piattaforme (Android, iOS, Web, desktop) e perché avere un database relazionale vero significa poter usare join, viste e vincoli senza denormalizzare tutto come accade nei database a documenti.

In questo articolo vediamo come integrare Supabase in un'app Flutter reale: configurazione, autenticazione, accesso ai dati, realtime, sicurezza e struttura del codice.

Configurazione del progetto

Aggiungi la dipendenza:

dependencies:
  supabase_flutter: ^2.8.0

L'inizializzazione avviene una sola volta, prima di runApp:

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Supabase.initialize(
    url: const String.fromEnvironment('SUPABASE_URL'),
    anonKey: const String.fromEnvironment('SUPABASE_ANON_KEY'),
    authOptions: const FlutterAuthClientOptions(
      authFlowType: AuthFlowType.pkce, // consigliato per i client mobile
    ),
  );

  runApp(const MyApp());
}

/// Scorciatoia comoda per accedere al client
final supabase = Supabase.instance.client;

Le chiavi vanno passate con --dart-define (o --dart-define-from-file), non hardcodate nel sorgente:

flutter run --dart-define=SUPABASE_URL=https://xyz.supabase.co \
            --dart-define=SUPABASE_ANON_KEY=eyJhbGciOi...

Attenzione: nell'app va usata solo la chiave anon. La chiave service_role bypassa tutte le policy di sicurezza e deve restare esclusivamente lato server (Edge Function o backend).

Autenticazione

Email e password

class AuthRepository {
  final SupabaseClient _client;
  AuthRepository(this._client);

  Future<void> signUp({required String email, required String password}) async {
    await _client.auth.signUp(
      email: email,
      password: password,
      data: {'display_name': email.split('@').first}, // finisce in user_metadata
    );
  }

  Future<void> signIn({required String email, required String password}) async {
    await _client.auth.signInWithPassword(email: email, password: password);
  }

  Future<void> signOut() => _client.auth.signOut();

  User? get currentUser => _client.auth.currentUser;

  Stream<AuthState> get onAuthStateChange => _client.auth.onAuthStateChange;
}

Il client persiste automaticamente la sessione su disco e ne gestisce il refresh: al riavvio dell'app currentUser è già valorizzato, non serve implementare nulla a mano.

Reagire ai cambi di sessione

Il modo più pulito per decidere cosa mostrare è ascoltare onAuthStateChange:

class AuthGate extends StatelessWidget {
  const AuthGate({super.key});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<AuthState>(
      stream: supabase.auth.onAuthStateChange,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Scaffold(
            body: Center(child: CircularProgressIndicator()),
          );
        }
        final session = snapshot.data?.session ?? supabase.auth.currentSession;
        return session == null ? const LoginPage() : const HomePage();
      },
    );
  }
}

Se usi go_router, la stessa logica si traduce in un redirect con refreshListenable collegato allo stream.

Login OAuth e deep link

Per Google, Apple o GitHub il flusso apre un browser esterno e torna nell'app tramite deep link:

Future<void> signInWithGoogle() async {
  await supabase.auth.signInWithOAuth(
    OAuthProvider.google,
    redirectTo: kIsWeb ? null : 'io.miaapp://login-callback/',
    authScreenLaunchMode: LaunchMode.externalApplication,
  );
}

Su Android va dichiarato l'intent filter in AndroidManifest.xml:

<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.miaapp" android:host="login-callback" />
</intent-filter>

Su iOS lo schema va aggiunto in Info.plist sotto CFBundleURLTypes. Lo stesso URL di redirect deve essere inserito nella whitelist del pannello Supabase (Authentication → URL Configuration).

Per Apple e Google su mobile è preferibile il login nativo con signInWithIdToken, che evita il passaggio dal browser e rispetta le linee guida degli store.

Leggere e scrivere dati

Supabase espone automaticamente un'API REST (PostgREST) su ogni tabella. Le query in Dart sono fluent e restituiscono List<Map<String, dynamic>>:

class TodoRepository {
  final SupabaseClient _client;
  TodoRepository(this._client);

  Future<List<Todo>> fetchTodos({int page = 0, int pageSize = 20}) async {
    final data = await _client
        .from('todos')
        .select('id, title, is_done, created_at')
        .eq('user_id', _client.auth.currentUser!.id)
        .order('created_at', ascending: false)
        .range(page * pageSize, (page + 1) * pageSize - 1);

    return data.map(Todo.fromJson).toList();
  }

  Future<Todo> addTodo(String title) async {
    final row = await _client
        .from('todos')
        .insert({'title': title, 'user_id': _client.auth.currentUser!.id})
        .select()
        .single(); // ritorna la riga appena creata
    return Todo.fromJson(row);
  }

  Future<void> toggle(String id, bool isDone) async {
    await _client.from('todos').update({'is_done': isDone}).eq('id', id);
  }

  Future<void> delete(String id) async {
    await _client.from('todos').delete().eq('id', id);
  }
}

Un punto di forza rispetto ai database a documenti sono i join impliciti grazie alle foreign key:

final posts = await supabase
    .from('posts')
    .select('id, title, author:profiles(id, username, avatar_url), comments(count)')
    .order('created_at', ascending: false)
    .limit(20);

In una sola richiesta ottieni il post, i dati dell'autore e il numero di commenti.

Per logiche complesse conviene scrivere una funzione SQL e richiamarla con rpc:

final result = await supabase.rpc(
  'search_posts',
  params: {'query': 'flutter', 'max_results': 10},
);

Realtime: liste che si aggiornano da sole

Il metodo .stream() apre una connessione WebSocket e restituisce uno Stream che emette l'intero set di righe a ogni modifica:

class TodoListView extends StatefulWidget {
  const TodoListView({super.key});

  @override
  State<TodoListView> createState() => _TodoListViewState();
}

class _TodoListViewState extends State<TodoListView> {
  late final Stream<List<Todo>> _stream;

  @override
  void initState() {
    super.initState();
    _stream = supabase
        .from('todos')
        .stream(primaryKey: ['id'])
        .eq('user_id', supabase.auth.currentUser!.id)
        .order('created_at')
        .map((rows) => rows.map(Todo.fromJson).toList());
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<Todo>>(
      stream: _stream,
      builder: (context, snapshot) {
        if (snapshot.hasError) return const Text('Errore di caricamento');
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }
        final todos = snapshot.data!;
        return ListView.builder(
          itemCount: todos.length,
          itemBuilder: (context, i) => CheckboxListTile(
            title: Text(todos[i].title),
            value: todos[i].isDone,
            onChanged: (v) => supabase
                .from('todos')
                .update({'is_done': v})
                .eq('id', todos[i].id),
          ),
        );
      },
    );
  }
}

Due avvertenze importanti:

  • il realtime va abilitato sulla tabella (ALTER PUBLICATION supabase_realtime ADD TABLE todos; o dal pannello);
  • .stream() supporta solo filtri semplici (eq, neq, gt, inFilter…) e non i join: per query complesse combina un select() iniziale con un canale realtime manuale.

Per un controllo più fine puoi ascoltare i singoli eventi:

final channel = supabase
    .channel('public:messages')
    .onPostgresChanges(
      event: PostgresChangeEvent.insert,
      schema: 'public',
      table: 'messages',
      callback: (payload) {
        final message = Message.fromJson(payload.newRecord);
        _controller.add(message);
      },
    )
    .subscribe();

// ricordati di chiudere il canale
@override
void dispose() {
  supabase.removeChannel(channel);
  super.dispose();
}

Sicurezza: la Row Level Security non è opzionale

Con Supabase l'app parla direttamente col database: la sicurezza non sta nel codice Dart ma nelle policy PostgreSQL. Senza RLS attiva chiunque abbia la chiave anon (estraibile dal binario) può leggere l'intera tabella.

alter table todos enable row level security;

create policy "Gli utenti leggono i propri todo"
  on todos for select
  using (auth.uid() = user_id);

create policy "Gli utenti creano i propri todo"
  on todos for insert
  with check (auth.uid() = user_id);

create policy "Gli utenti modificano i propri todo"
  on todos for update
  using (auth.uid() = user_id);

Regola pratica: attiva RLS su ogni tabella esposta, poi scrivi le policy. Se una query dal client restituisce una lista vuota quando ti aspetti dei dati, quasi sempre il colpevole è una policy mancante.

Per default, inoltre, imposta le colonne come user_id uuid references auth.users default auth.uid(): eviti di doverle passare dal client.

Storage dei file

Future<String> uploadAvatar(File file) async {
  final userId = supabase.auth.currentUser!.id;
  final path = '$userId/avatar.jpg';

  await supabase.storage.from('avatars').upload(
        path,
        file,
        fileOptions: const FileOptions(upsert: true, contentType: 'image/jpeg'),
      );

  // bucket pubblico
  return supabase.storage.from('avatars').getPublicUrl(path);

  // bucket privato: URL firmato valido 1 ora
  // return supabase.storage.from('avatars').createSignedUrl(path, 3600);
}

Anche i bucket hanno le loro policy: strutturare i path con l'uid dell'utente come prima cartella rende banale scrivere regole del tipo (storage.foldername(name))[1] = auth.uid()::text.

Gestione degli errori

Il SDK lancia eccezioni tipizzate: intercettale nel repository e traducile in errori di dominio.

Future<Result<Todo>> addTodo(String title) async {
  try {
    final row = await _client.from('todos').insert({'title': title}).select().single();
    return Success(Todo.fromJson(row));
  } on PostgrestException catch (e) {
    // e.code '23505' = violazione di unique, '42501' = policy RLS
    return Failure(DatabaseError(e.message, code: e.code));
  } on AuthException catch (e) {
    return Failure(AuthError(e.message));
  } on SocketException {
    return Failure(const NetworkError());
  }
}

Best practice

  • Isola il SDK dietro dei repository. Non chiamare supabase.from(...) dentro i widget: rende impossibili i test e lega la UI al backend.
  • Modelli tipizzati. Le risposte sono mappe dinamiche: converti subito con fromJson generati da freezed/json_serializable.
  • PKCE e sessione. Usa AuthFlowType.pkce sui client mobile e non salvare mai token a mano: ci pensa il SDK.
  • Chiudi i canali realtime in dispose(), altrimenti accumuli sottoscrizioni WebSocket.
  • Paginazione con range(), mai select() senza limiti su tabelle che possono crescere.
  • Migrazioni versionate con la Supabase CLI (supabase migration new), così lo schema vive nel repository insieme al codice Flutter.
  • Offline first: Supabase non ha una cache locale integrata come Firestore. Se ti serve, affianca un database locale (Drift o Isar) come single source of truth e sincronizza con updated_at.

Conclusioni

Supabase offre a Flutter un backend completo in poche righe di configurazione, con il vantaggio di un database relazionale standard e la possibilità di self-hosting. Il modello mentale è però diverso da quello di un'API REST tradizionale: la logica di autorizzazione si sposta nelle policy SQL, e progettare bene schema e RLS fin dall'inizio è la parte più importante del lavoro. Fatto questo, il codice Dart resta sorprendentemente sottile: repository, modelli tipizzati e stream che aggiornano la UI in tempo reale.