Why Flutter + Supabase?
- Cross-platform - Build for iOS and Android from a single codebase
- Native performance with Flutter’s engine
- Real-time subscriptions for live updates
- Type-safe Dart integration
- Offline support with local caching
- Deep links for OAuth and magic links
Quick Start
Install Supabase
Add to Install dependencies:
pubspec.yaml:dependencies:
flutter:
sdk: flutter
supabase_flutter: ^2.0.0
flutter pub get
Configure Deep Links
For iOS, update For Android, update
ios/Runner/Info.plist:<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>io.supabase.myapp</string>
</array>
</dict>
</array>
android/app/src/main/AndroidManifest.xml:<manifest ...>
<application ...>
<activity ...>
<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.supabase.myapp" />
</intent-filter>
</activity>
</application>
</manifest>
Initialize Supabase
Update
lib/main.dart:import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'your-project-url',
anonKey: 'your-anon-key',
);
runApp(const MyApp());
}
final supabase = Supabase.instance.client;
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Supabase Flutter',
theme: ThemeData.dark().copyWith(
primaryColor: Colors.green,
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.green,
),
),
),
home: const SplashPage(),
);
}
}
Authentication
Splash Screen
Create a splash screen to check auth status:class SplashPage extends StatefulWidget {
const SplashPage({Key? key}) : super(key: key);
@override
State<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
@override
void initState() {
super.initState();
_redirect();
}
Future<void> _redirect() async {
await Future.delayed(Duration.zero);
final session = supabase.auth.currentSession;
if (session != null) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomePage()),
);
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const LoginPage()),
);
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
}
Login Page
class LoginPage extends StatefulWidget {
const LoginPage({Key? key}) : super(key: key);
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
Future<void> _signIn() async {
setState(() {
_isLoading = true;
});
try {
await supabase.auth.signInWithPassword(
email: _emailController.text,
password: _passwordController.text,
);
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomePage()),
);
}
} on AuthException catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.message)),
);
}
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unexpected error occurred')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _signUp() async {
setState(() {
_isLoading = true;
});
try {
await supabase.auth.signUp(
email: _emailController.text,
password: _passwordController.text,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Check your email for confirmation')),
);
}
} on AuthException catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.message)),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign In')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _signIn,
child: Text(_isLoading ? 'Loading...' : 'Sign In'),
),
const SizedBox(height: 8),
TextButton(
onPressed: _isLoading ? null : _signUp,
child: const Text('Sign Up'),
),
],
),
);
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
}
Database Operations
Fetching Data
class PostsPage extends StatefulWidget {
const PostsPage({Key? key}) : super(key: key);
@override
State<PostsPage> createState() => _PostsPageState();
}
class _PostsPageState extends State<PostsPage> {
List<Map<String, dynamic>> _posts = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_fetchPosts();
}
Future<void> _fetchPosts() async {
setState(() {
_isLoading = true;
});
try {
final response = await supabase
.from('posts')
.select()
.order('created_at', ascending: false);
setState(() {
_posts = List<Map<String, dynamic>>.from(response);
});
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading posts: $error')),
);
}
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Posts')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: _posts.length,
itemBuilder: (context, index) {
final post = _posts[index];
return Card(
margin: const EdgeInsets.all(8),
child: ListTile(
title: Text(post['title'] ?? ''),
subtitle: Text(post['content'] ?? ''),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const CreatePostPage()),
).then((_) => _fetchPosts()),
child: const Icon(Icons.add),
),
);
}
}
Inserting Data
class CreatePostPage extends StatefulWidget {
const CreatePostPage({Key? key}) : super(key: key);
@override
State<CreatePostPage> createState() => _CreatePostPageState();
}
class _CreatePostPageState extends State<CreatePostPage> {
final _titleController = TextEditingController();
final _contentController = TextEditingController();
bool _isLoading = false;
Future<void> _createPost() async {
setState(() {
_isLoading = true;
});
try {
await supabase.from('posts').insert({
'title': _titleController.text,
'content': _contentController.text,
'user_id': supabase.auth.currentUser!.id,
});
if (mounted) {
Navigator.of(context).pop();
}
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error creating post: $error')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create Post')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
const SizedBox(height: 16),
TextField(
controller: _contentController,
decoration: const InputDecoration(labelText: 'Content'),
maxLines: 5,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _createPost,
child: Text(_isLoading ? 'Creating...' : 'Create Post'),
),
],
),
);
}
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
}
Real-time Subscriptions
class RealtimePostsPage extends StatefulWidget {
const RealtimePostsPage({Key? key}) : super(key: key);
@override
State<RealtimePostsPage> createState() => _RealtimePostsPageState();
}
class _RealtimePostsPageState extends State<RealtimePostsPage> {
List<Map<String, dynamic>> _posts = [];
late final RealtimeChannel _channel;
@override
void initState() {
super.initState();
_loadPosts();
_setupRealtimeSubscription();
}
Future<void> _loadPosts() async {
final response = await supabase
.from('posts')
.select()
.order('created_at', ascending: false);
setState(() {
_posts = List<Map<String, dynamic>>.from(response);
});
}
void _setupRealtimeSubscription() {
_channel = supabase
.channel('posts')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'posts',
callback: (payload) {
setState(() {
_posts.insert(0, payload.newRecord);
});
},
)
.onPostgresChanges(
event: PostgresChangeEvent.delete,
schema: 'public',
table: 'posts',
callback: (payload) {
setState(() {
_posts.removeWhere((post) => post['id'] == payload.oldRecord['id']);
});
},
)
.subscribe();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Real-time Posts')),
body: ListView.builder(
itemCount: _posts.length,
itemBuilder: (context, index) {
final post = _posts[index];
return Card(
margin: const EdgeInsets.all(8),
child: ListTile(
title: Text(post['title'] ?? ''),
subtitle: Text(post['content'] ?? ''),
),
);
},
),
);
}
@override
void dispose() {
supabase.removeChannel(_channel);
super.dispose();
}
}
File Upload
import 'package:image_picker/image_picker.dart';
import 'dart:io';
class AvatarUpload extends StatefulWidget {
const AvatarUpload({Key? key}) : super(key: key);
@override
State<AvatarUpload> createState() => _AvatarUploadState();
}
class _AvatarUploadState extends State<AvatarUpload> {
String? _avatarUrl;
bool _isLoading = false;
Future<void> _uploadAvatar() async {
final picker = ImagePicker();
final imageFile = await picker.pickImage(source: ImageSource.gallery);
if (imageFile == null) return;
setState(() {
_isLoading = true;
});
try {
final bytes = await imageFile.readAsBytes();
final fileExt = imageFile.path.split('.').last;
final fileName = '${DateTime.now().toIso8601String()}.$fileExt';
final filePath = '${supabase.auth.currentUser!.id}/$fileName';
await supabase.storage.from('avatars').uploadBinary(
filePath,
bytes,
);
final publicUrl = supabase.storage.from('avatars').getPublicUrl(filePath);
setState(() {
_avatarUrl = publicUrl;
});
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error uploading avatar: $error')),
);
}
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
if (_avatarUrl != null)
CircleAvatar(
radius: 50,
backgroundImage: NetworkImage(_avatarUrl!),
)
else
const CircleAvatar(
radius: 50,
child: Icon(Icons.person, size: 50),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _uploadAvatar,
child: Text(_isLoading ? 'Uploading...' : 'Upload Avatar'),
),
],
);
}
}
Best Practices
Dispose Resources
Always dispose controllers and unsubscribe from channels.
Error Handling
Use try-catch blocks and show user-friendly error messages.
Loading States
Show loading indicators during async operations.
Deep Links
Configure deep links for OAuth and magic link authentication.
State Management
Consider using Provider, Riverpod, or Bloc for complex apps.
Next Steps
User Management
Build user profiles with avatars
File Uploads
Handle file storage
Realtime Chat
Build a chat app
Examples
Explore Flutter examples
