The whole app is three tables
Strip away the UI and every chat app is the same data model:
| Table | Holds | Key fields |
|---|---|---|
| users | People | id, displayName, avatar |
| conversations | A thread between people | id, memberIds, updatedAt |
| messages | Each line sent | id, conversationId, senderId, text, createdAt |
That’s it. Group chats, DMs, support inboxes — all the same three tables, differing only in how many members a conversation has. Everything else you might add (read receipts, reactions, typing indicators) is a column or a small table on top of this base.
Step 1 — Pick the real-time backend
This is the one decision that shapes the project. Honest trade-offs:
- Supabase Realtime— Postgres database plus real-time subscriptions in one. Best default for an indie app: you own the data, row-level security handles access, and “new message appears instantly” is a few lines. This is what pairs naturally with a generated app.
- Firebase Firestore— real-time by default, generous free tier, great if you’re already in the Google ecosystem. Querying is more limited than SQL.
- Stream / Sendbird — a full chat service with typing indicators, read receipts, and moderation built in. Buy this when chat is the product and you need it to scale — you pay per monthly active user.
For a messaging feature inside a larger app, start with Supabase. For a chat-first product at scale, price out Stream early.
Step 2 — Authentication comes first
Every message needs a known sender, so auth isn’t optional and it isn’t last. Sign users in first, tie each message.senderIdto the authenticated user, and use row-level security so a user can only read conversations they’re a member of. Skipping RLS is the classic chat-app security hole — without it, anyone can read anyone’s messages by guessing an id.
The auth guide for AI-built apps covers Supabase Auth, Clerk, and Auth0 side by side.
Step 3 — The three screens
A chat app is a small, well-defined set of screens:
- Conversation list — each thread with the last message and timestamp, most recent first.
- Thread view — the message list (inverted, so newest is at the bottom) with a text input pinned to the keyboard.
- New chat — pick a person (or people) and start a conversation.
The thread view is the only fiddly one: use an inverted FlatList so it scrolls from the bottom, and keep the input above the keyboard with KeyboardAvoidingView. Both are standard patterns.
Step 4 — Make it real-time
The magic step is short. When a thread is open, subscribe to new rows in that conversation and append them to state. With Supabase it’s roughly:
const channel = supabase
.channel('messages:' + conversationId)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages',
filter: 'conversationId=eq.' + conversationId },
(payload) => setMessages((prev) => [...prev, payload.new])
)
.subscribe();
// clean up when the screen unmounts
return () => supabase.removeChannel(channel);No socket code, no polling. You insert a message like any row; every subscribed client sees it appear. Add push notifications so backgrounded users get pinged, and the core loop is complete.
The shortcut: generate the scaffold
The three tables, the three screens, the auth wiring, the inverted list — all of it is standard, and standard is what an AI builder generates well. Describe “a messaging app where users start conversations and send real-time messages” in ShipNative and it scaffolds the conversation list, thread view, message model, and a Supabase backend with realtime — previewed live on your phone.
You still make the product calls — group vs DM, moderation, notification rules — but you start from a working chat instead of an empty file. See how to connect the real database behind it.
Build a chat app free
Describe your chat app in one sentence and see it running on your phone in minutes at shipnative.dev. No credit card, and you export the full Expo project any time.