We're building a real one-on-one chat app. Supabase holds the messages and streams changes. Flutter shows a chat view that scrolls, sends, receives, and handles the small UX details that separate a weekend demo from something you could actually ship. No Firebase, no websockets plumbing you own, no background workers. Just Postgres replication doing the hard work for us.

If you did the Notes App project first, you already know most of the moves. This one focuses on the chat-specific patterns: ordered messages, scroll-to-bottom behavior that doesn't fight the user, and optimistic sends.

The data model

The whole app lives on one table: messages. Each row is one message. sender_id and recipient_id both reference auth.users. Content is plain text for now — images and files are a follow-up.

SQL editorsql
create table messages (
id uuid primary key default gen_random_uuid(),
sender_id uuid references auth.users not null default auth.uid(),
recipient_id uuid references auth.users not null,
content text not null,
created_at timestamptz not null default now()
);

-- Index on (sender, recipient, time) so the chat query is cheap.
create index messages_conversation_idx on messages
(least(sender_id, recipient_id), greatest(sender_id, recipient_id), created_at);

alter table messages enable row level security;

-- You can see a message if you sent it or if it was sent to you.
create policy "chat participants can read"
on messages for select using (
  auth.uid() = sender_id or auth.uid() = recipient_id
);

-- You can only insert messages you're sending.
create policy "sender can insert"
on messages for insert with check (auth.uid() = sender_id);

-- You can only delete your own messages.
create policy "sender can delete"
on messages for delete using (auth.uid() = sender_id);

Now turn on realtime replication for this table: Database → Replication, find messages, toggle it on. Without this, the client stream will load the initial data once and then never update.

Fetch messages as a stream

We want a sorted list of messages between two users that updates live. Supabase's stream() returns all rows for a table that match the filter, then fires again whenever anything changes. RLS handles filtering to the current user's messages, but we still need to narrow to the specific conversation.

lib/chat_repository.dartdart
import 'package:supabase_flutter/supabase_flutter.dart';

class Message {
final String id;
final String senderId;
final String recipientId;
final String content;
final DateTime createdAt;

Message.fromJson(Map<String, dynamic> json)
    : id = json['id'] as String,
      senderId = json['sender_id'] as String,
      recipientId = json['recipient_id'] as String,
      content = json['content'] as String,
      createdAt = DateTime.parse(json['created_at'] as String);
}

class ChatRepository {
ChatRepository(this._supabase);
final SupabaseClient _supabase;

/// Live stream of every message in this conversation, oldest first.
/// Relies on RLS to scope the results to the current user automatically.
Stream<List<Message>> messages(String otherUserId) {
  return _supabase
      .from('messages')
      .stream(primaryKey: ['id'])
      .order('created_at', ascending: true)
      .map((rows) => rows
          .where((row) {
            final sender = row['sender_id'] as String;
            final recipient = row['recipient_id'] as String;
            final me = _supabase.auth.currentUser!.id;
            return (sender == me && recipient == otherUserId) ||
                (sender == otherUserId && recipient == me);
          })
          .map(Message.fromJson)
          .toList());
}

Future<void> send({
  required String to,
  required String content,
}) async {
  await _supabase.from('messages').insert({
    'recipient_id': to,
    'content': content.trim(),
  });
}
}

The chat screen

The UI has four moving parts:

  1. A ListView of messages, oldest at the top
  2. An input field at the bottom
  3. A scroll controller that jumps to the latest message when new ones arrive, but only if the user was already at the bottom
  4. An empty state for brand new conversations
lib/chat_screen.dartdart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'chat_repository.dart';

class ChatScreen extends StatefulWidget {
const ChatScreen({
  super.key,
  required this.otherUserId,
  required this.otherUserName,
});

final String otherUserId;
final String otherUserName;

@override
State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
late final ChatRepository _repo;
late final Stream<List<Message>> _stream;
final _input = TextEditingController();
final _scroll = ScrollController();
bool _wasAtBottom = true;

@override
void initState() {
  super.initState();
  _repo = ChatRepository(Supabase.instance.client);
  _stream = _repo.messages(widget.otherUserId);
  // Track whether the user is pinned to the latest message so we only
  // auto-scroll when they haven't scrolled up to read history.
  _scroll.addListener(() {
    _wasAtBottom = _scroll.position.pixels >=
        _scroll.position.maxScrollExtent - 40;
  });
}

@override
void dispose() {
  _input.dispose();
  _scroll.dispose();
  super.dispose();
}

void _scrollToBottom() {
  if (!_scroll.hasClients) return;
  _scroll.animateTo(
    _scroll.position.maxScrollExtent,
    duration: const Duration(milliseconds: 200),
    curve: Curves.easeOut,
  );
}

Future<void> _send() async {
  final text = _input.text.trim();
  if (text.isEmpty) return;
  _input.clear();
  await _repo.send(to: widget.otherUserId, content: text);
  // New message will arrive via stream; _wasAtBottom means we scroll.
}

@override
Widget build(BuildContext context) {
  final me = Supabase.instance.client.auth.currentUser!.id;

  return Scaffold(
    appBar: AppBar(title: Text(widget.otherUserName)),
    body: Column(
      children: [
        Expanded(
          child: StreamBuilder<List<Message>>(
            stream: _stream,
            builder: (context, snapshot) {
              if (!snapshot.hasData) {
                return const Center(child: CircularProgressIndicator());
              }
              final messages = snapshot.data!;
              if (messages.isEmpty) {
                return const Center(
                  child: Text('Say hi.'),
                );
              }
              // Schedule a scroll after this frame paints the new list,
              // so the ScrollController sees the updated maxScrollExtent.
              WidgetsBinding.instance.addPostFrameCallback((_) {
                if (_wasAtBottom) _scrollToBottom();
              });
              return ListView.builder(
                controller: _scroll,
                padding: const EdgeInsets.all(16),
                itemCount: messages.length,
                itemBuilder: (_, i) {
                  final msg = messages[i];
                  final mine = msg.senderId == me;
                  return _Bubble(message: msg, mine: mine);
                },
              );
            },
          ),
        ),
        SafeArea(
          top: false,
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _input,
                    minLines: 1,
                    maxLines: 5,
                    decoration: const InputDecoration(
                      hintText: 'Type a message...',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (_) => _send(),
                  ),
                ),
                const SizedBox(width: 8),
                IconButton.filled(
                  onPressed: _send,
                  icon: const Icon(Icons.send),
                ),
              ],
            ),
          ),
        ),
      ],
    ),
  );
}
}

class _Bubble extends StatelessWidget {
const _Bubble({required this.message, required this.mine});
final Message message;
final bool mine;

@override
Widget build(BuildContext context) {
  final theme = Theme.of(context);
  return Align(
    alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
    child: Container(
      margin: const EdgeInsets.symmetric(vertical: 4),
      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
      constraints: BoxConstraints(
        maxWidth: MediaQuery.sizeOf(context).width * 0.75,
      ),
      decoration: BoxDecoration(
        color: mine
            ? theme.colorScheme.primary
            : theme.colorScheme.surfaceContainerHighest,
        borderRadius: BorderRadius.only(
          topLeft: const Radius.circular(18),
          topRight: const Radius.circular(18),
          bottomLeft: Radius.circular(mine ? 18 : 4),
          bottomRight: Radius.circular(mine ? 4 : 18),
        ),
      ),
      child: Text(
        message.content,
        style: TextStyle(
          color: mine
              ? theme.colorScheme.onPrimary
              : theme.colorScheme.onSurface,
        ),
      ),
    ),
  );
}
}

Small UX details that matter

The code above is functionally complete, but a real chat app is a pile of micro-details. These are the ones I always add before I consider a chat feature "done":

  • Don't auto-scroll if the user is reading history. The _wasAtBottom flag above handles this — if the user scrolled up to re-read something, a new incoming message shouldn't yank them away.
  • Trim the input on send, clear on send, re-focus the field. Three lines each, instantly better feel.
  • Multi-line input. minLines: 1, maxLines: 5 gives you an input that grows up to 5 lines and then scrolls.
  • Dismiss the keyboard on drag. Add keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag to the ListView so a scroll gesture closes the keyboard. Feels native.
  • Show typing indicators via Supabase Realtime presence (not covered here — see the realtime guide for the presence API).

What to add next

This version works and you can actually use it for a private chat between two accounts. Before you'd call it shipped:

  • A conversations list screen — query messages grouped by the other participant, order by most recent, show an unread indicator. The whole thing is one Postgres query.
  • Read receipts — add a read_at timestamp column, update it when the recipient opens the chat. Supabase Realtime picks up the update and the sender's bubble can show "seen".
  • Image attachments — use Supabase Storage to upload, put the storage path in a media_url column on the message row.
  • Push notifications — the only part that actually needs a third-party service. RevenueCat isn't the right tool — this is where you'd add Firebase Cloud Messaging or OneSignal.

That's a full chat app with Postgres underneath it. No message queue, no custom WebSocket server, no background sync daemon. Realtime in Supabase is one of the features that genuinely changes what you can ship solo.