Flutter

Build a Flutter app on Plexivo Postgres: sign-in that survives restarts, a live list of the user’s own rows, and errors your users understand.

This guide builds a notes app in Flutter: people sign up, stay signed in across launches, and see their notes change live on every device. The same code runs on Android, iOS, the web and desktop.

Before you begin#

  • A Plexivo Postgres with the Data API on. For Flutter web, add your app’s address to its allowed origins. See Build a backend in five minutes.
  • The notes table below, created with the secret key.
sql
create table notes (
  id bigint generated always as identity primary key,
  owner uuid not null default auth.uid(),
  body text not null,
  created_at timestamptz not null default now()
);
alter table notes enable row level security;
create policy notes_owner on notes for all to authenticated
  using (owner = auth.uid()) with check (owner = auth.uid());
create trigger plexivo_realtime after insert or update or delete on notes
  for each row execute function auth.realtime_notify('id');

1. Add the packages#

bash
flutter create notes
cd notes
flutter pub add plexivo_client flutter_secure_storage

Pass your Project URL and publishable key when you run the app, rather than writing them in the source:

bash
flutter run --dart-define=PLEXIVO_DATA_URL=https://… --dart-define=PLEXIVO_PUBLISHABLE_KEY=plx_publishable_…

2. The data#

Keep the calls to Plexivo in one class, so your widgets stay simple and you can test it on its own:

dart
import 'package:plexivo_client/plexivo_client.dart';

class NotesRepository {
  NotesRepository(this.db);

  final PlexivoClient db;

  Future<List<Map<String, dynamic>>> load() => db.from('notes').order('created_at', 'desc').limit(50);

  Future<void> add(String body) => db.from('notes').create({'body': body});

  /// Row changes the signed-in user may read, as they happen.
  PlexivoRealtimeChannel watch() => db.channel('changes:notes')..subscribe();

  /// Applies one change to a list.
  static List<Map<String, dynamic>> apply(List<Map<String, dynamic>> notes, PlexivoChange change) => switch (change.type) {
        'insert' => [change.record!, ...notes.where((n) => n['id'] != change.record!['id'])],
        'update' => [for (final n in notes) n['id'] == change.record!['id'] ? change.record! : n],
        _ => notes.where((n) => n['id'] != change.old?['id']).toList(),
      };
}

3. Stay signed in#

Save the session in encrypted storage whenever it changes, and give it back to the client at the next launch. The client refreshes the access token itself:

dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:plexivo_client/plexivo_client.dart';

import 'notes_repository.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  const storage = FlutterSecureStorage();
  final saved = await storage.read(key: 'plexivo_session');

  final db = PlexivoClient(
    const String.fromEnvironment('PLEXIVO_DATA_URL'),
    const String.fromEnvironment('PLEXIVO_PUBLISHABLE_KEY'),
    initialSession: saved == null ? null : jsonDecode(saved) as Map<String, dynamic>,
  );
  db.authStateChanges.listen((state) {
    final session = state.session;
    if (session == null) {
      storage.delete(key: 'plexivo_session');
    } else {
      storage.write(key: 'plexivo_session', value: jsonEncode(session));
    }
  });

  runApp(MaterialApp(home: NotesApp(db: db)));
}

class NotesApp extends StatelessWidget {
  const NotesApp({super.key, required this.db});

  final PlexivoClient db;

  @override
  Widget build(BuildContext context) => StreamBuilder<PlexivoAuthState>(
        stream: db.authStateChanges,
        builder: (context, _) => db.session == null ? SignInScreen(db: db) : NotesScreen(repository: NotesRepository(db)),
      );
}

class SignInScreen extends StatefulWidget {
  const SignInScreen({super.key, required this.db});

  final PlexivoClient db;

  @override
  State<SignInScreen> createState() => _SignInScreenState();
}

class _SignInScreenState extends State<SignInScreen> {
  final _email = TextEditingController();
  final _password = TextEditingController();
  String? _error;

  Future<void> _submit({required bool create}) async {
    try {
      if (create) {
        await widget.db.signUp(email: _email.text, password: _password.text);
      } else {
        await widget.db.signInWithPassword(email: _email.text, password: _password.text);
      }
    } on PlexivoException catch (e) {
      setState(() => _error = switch (e.code) {
            'invalid_credentials' => 'Wrong email or password.',
            'email_taken' => 'That email already has an account.',
            'weak_password' => 'Use at least 10 characters.',
            _ => e.message,
          });
    }
  }

  @override
  Widget build(BuildContext context) => Scaffold(
        body: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(children: [
            TextField(controller: _email, decoration: const InputDecoration(labelText: 'Email')),
            TextField(controller: _password, obscureText: true, decoration: const InputDecoration(labelText: 'Password')),
            if (_error != null) Text(_error!, style: const TextStyle(color: Colors.red)),
            FilledButton(onPressed: () => _submit(create: false), child: const Text('Sign in')),
            TextButton(onPressed: () => _submit(create: true), child: const Text('Create account')),
          ]),
        ),
      );
}

class NotesScreen extends StatefulWidget {
  const NotesScreen({super.key, required this.repository});

  final NotesRepository repository;

  @override
  State<NotesScreen> createState() => _NotesScreenState();
}

class _NotesScreenState extends State<NotesScreen> {
  final _body = TextEditingController();
  late final PlexivoRealtimeChannel _channel;
  List<Map<String, dynamic>> _notes = [];

  @override
  void initState() {
    super.initState();
    widget.repository.load().then((notes) => setState(() => _notes = notes));
    _channel = widget.repository.watch();
    _channel.changes.listen((change) => setState(() => _notes = NotesRepository.apply(_notes, change)));
  }

  @override
  void dispose() {
    _channel.unsubscribe();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text('Notes'), actions: [
          IconButton(icon: const Icon(Icons.logout), onPressed: () => widget.repository.db.signOut()),
        ]),
        body: ListView(children: [for (final n in _notes) ListTile(title: Text(n['body'] as String))]),
        bottomNavigationBar: Padding(
          padding: const EdgeInsets.all(12),
          child: TextField(
            controller: _body,
            decoration: const InputDecoration(hintText: 'New note'),
            onSubmitted: (text) async {
              await widget.repository.add(text);
              _body.clear();
            },
          ),
        ),
      );
}

The list holds only the signed-in user’s notes, because row-level security decides what each request and each realtime change may show. When the connection drops or the token expires, the channel rejoins by itself.

4. Try it without a phone#

The repository is plain Dart, so a script or a test can drive it against your project. Save this as bin/try_notes.dart and run dart run with the same --define values:

dart
import 'package:notes/notes_repository.dart';
import 'package:plexivo_client/plexivo_client.dart';

Future<void> main() async {
  final db = PlexivoClient(
    const String.fromEnvironment('PLEXIVO_DATA_URL'),
    const String.fromEnvironment('PLEXIVO_PUBLISHABLE_KEY'),
  );
  await db.signUp(email: 'tester-${DateTime.now().millisecondsSinceEpoch}@example.com', password: 'a long test password');

  final notes = NotesRepository(db);
  final channel = notes.watch();
  await channel.ready();
  channel.changes.listen((change) => print('${change.type} ${change.record?['body']}'));

  await notes.add('Written from a test');
  print('${(await notes.load()).length} note');
  await Future<void>.delayed(const Duration(seconds: 2));
  await channel.unsubscribe();
}

Next steps#

Updated 28 October 2026