We're going to build a notes app from scratch. Auth with email and password, notes stored in Supabase Postgres, Row Level Security so every user only sees their own notes, and realtime sync so typing on one device updates every other device instantly. The whole app is maybe 300 lines of Dart by the end.
This is the project I use whenever I want to try a new Flutter pattern because it touches every piece of a real app: auth, a backend, persistent data, and a reactive UI. Finish this once and you have a template you can reuse for basically any CRUD app.
Create the Supabase project
Head to the Supabase dashboard and click New project. Give it a name, a database password (save this somewhere, you'll rarely need it but you can't recover it), and a region close to your users. Supabase will provision a Postgres database, an auth service, and an API in about 60 seconds.
Once it's ready, go to Project Settings → API. You'll see two things we care about: the Project URL (looks like https://abc123.supabase.co) and the anon public key (a long string starting with eyJ). Copy both somewhere handy.
Create the Flutter project
In your terminal:
flutter create notes_app
cd notes_appOpen pubspec.yaml and add the Supabase Flutter package:
dependencies:
flutter:
sdk: flutter
supabase_flutter: ^2.6.0Run flutter pub get to install it.
Now replace lib/main.dart with the initialization boilerplate. The Supabase client reads from the URL and anon key you copied earlier:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
const supabaseUrl = 'https://YOUR-PROJECT.supabase.co';
const supabaseAnonKey = 'YOUR-ANON-KEY';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: supabaseUrl,
anonKey: supabaseAnonKey,
);
runApp(const NotesApp());
}
/// Global accessor so we don't have to pass this around.
final supabase = Supabase.instance.client;
class NotesApp extends StatelessWidget {
const NotesApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Notes',
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF3ECF8E),
brightness: Brightness.light,
),
home: const AuthGate(),
);
}
}The auth gate
AuthGate is the widget that decides whether to show the sign-in screen or the notes list. It listens to the Supabase auth state and rebuilds whenever the user logs in or out. No separate routing library needed for this size of app.
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'main.dart';
import 'sign_in_screen.dart';
import 'notes_screen.dart';
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<AuthState>(
stream: supabase.auth.onAuthStateChange,
builder: (context, snapshot) {
// Still figuring out whether there's a session on disk.
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final session = supabase.auth.currentSession;
if (session == null) {
return const SignInScreen();
}
return const NotesScreen();
},
);
}
}The nice thing about this pattern: when signOut() is called anywhere in the app, AuthGate rebuilds and drops the user back to sign-in. No manual navigation needed.
The sign-in screen
Minimal email + password form. Handles both sign-up and sign-in with a single toggle. Keeping both on one screen reduces the number of files and matches how modern apps do it anyway.
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'main.dart';
class SignInScreen extends StatefulWidget {
const SignInScreen({super.key});
@override
State<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends State<SignInScreen> {
final _email = TextEditingController();
final _password = TextEditingController();
bool _isSignUp = false;
bool _loading = false;
Future<void> _submit() async {
setState(() => _loading = true);
try {
if (_isSignUp) {
await supabase.auth.signUp(
email: _email.text.trim(),
password: _password.text,
);
} else {
await supabase.auth.signInWithPassword(
email: _email.text.trim(),
password: _password.text,
);
}
// AuthGate will handle the redirect.
} on AuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Notes',
style: TextStyle(fontSize: 40, fontWeight: FontWeight.w700),
),
const SizedBox(height: 32),
TextField(
controller: _email,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
autocorrect: false,
),
const SizedBox(height: 12),
TextField(
controller: _password,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _loading ? null : _submit,
child: Text(_loading
? 'Loading...'
: _isSignUp
? 'Create account'
: 'Sign in'),
),
TextButton(
onPressed: () => setState(() => _isSignUp = !_isSignUp),
child: Text(_isSignUp
? 'Have an account? Sign in'
: 'New here? Create an account'),
),
],
),
),
),
);
}
}The notes table
Open the Supabase dashboard, go to the SQL editor, and paste this. It creates the notes table and the four Row Level Security policies that make sure each user only sees, inserts, updates, and deletes their own notes.
create table notes (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null default auth.uid(),
content text not null,
created_at timestamptz not null default now()
);
-- Turn on RLS — without this, the table would be open to anyone with
-- the anon key.
alter table notes enable row level security;
-- Four policies: one for each CRUD verb. auth.uid() returns the ID of
-- the currently signed-in user.
create policy "notes are visible to their owner"
on notes for select using (auth.uid() = user_id);
create policy "users can insert their own notes"
on notes for insert with check (auth.uid() = user_id);
create policy "users can update their own notes"
on notes for update using (auth.uid() = user_id);
create policy "users can delete their own notes"
on notes for delete using (auth.uid() = user_id);One more thing: we need realtime for this table to get live updates. Go to Database → Replication, find the notes table, and toggle it on. This tells Postgres to broadcast row changes to any connected clients.
The notes screen
The heart of the app. A single screen with three things: a realtime-driven list of notes, a text field at the bottom for adding new ones, and a sign-out button. We use stream() from the Supabase client, which returns a broadcast stream of the full result set every time something changes. StreamBuilder does the rest.
import 'package:flutter/material.dart';
import 'main.dart';
class NotesScreen extends StatefulWidget {
const NotesScreen({super.key});
@override
State<NotesScreen> createState() => _NotesScreenState();
}
class _NotesScreenState extends State<NotesScreen> {
final _input = TextEditingController();
// A broadcast stream of every note for this user, ordered by time.
// Fires again any time a row is inserted, updated, or deleted.
final _notesStream = supabase
.from('notes')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false);
Future<void> _addNote() async {
final text = _input.text.trim();
if (text.isEmpty) return;
_input.clear();
await supabase.from('notes').insert({'content': text});
}
Future<void> _deleteNote(String id) async {
await supabase.from('notes').delete().eq('id', id);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Notes'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => supabase.auth.signOut(),
),
],
),
body: Column(
children: [
Expanded(
child: StreamBuilder<List<Map<String, dynamic>>>(
stream: _notesStream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final notes = snapshot.data!;
if (notes.isEmpty) {
return const Center(
child: Text('No notes yet. Add one below.'),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: notes.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, i) {
final note = notes[i];
return Dismissible(
key: ValueKey(note['id']),
background: Container(color: Colors.red),
onDismissed: (_) => _deleteNote(note['id']),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(note['content']),
),
),
);
},
);
},
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _input,
decoration: const InputDecoration(
hintText: 'Write a note...',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addNote(),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _addNote,
icon: const Icon(Icons.send),
),
],
),
),
),
],
),
);
}
}That's the whole app. Run flutter run, create an account, and start adding notes. Open the Supabase dashboard and look at the notes table — you should see your rows appearing live. Better yet, open the app on a second device (or the web build, or a simulator), sign in with the same account, and watch both screens update as you type on either.
What to add next
This is the minimum viable version. If you want to turn it into something you'd actually ship, here are the next things I'd add in order:
- Google and Apple sign-in — most users won't type out an email and password. See the Supabase auth guide for the native flows.
- Edit notes — tap a note to open an edit sheet, update with
.update({content: newText}).eq('id', id). Realtime picks up the change automatically. - Offline cache — wrap the stream in a Hive-backed cache so the list shows instantly on launch. See the local storage guide.
- Build a real-time chat instead — once you're comfortable with this flow, the Chat App project is the natural next step.
You now have a working Flutter + Supabase template with auth, a typed database, RLS, and realtime. Three of the hardest parts of shipping a mobile app, handled.