●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Rork Max Social App Features Guide — Timeline, Follow System & Real-Time Feed
A production-grade walkthrough for building follow systems, timeline feeds, optimistic likes/comments, real-time sync, and push notifications in Rork Max with Supabase — distilled from years of running quiet indie utility apps in production.
I build indie iOS and Android apps — a small portfolio of wallpaper, calming-tone, and intention-style utilities. For most of their life these apps were intentionally quiet: single-purpose tools that did one thing well. But over the last couple of years, the same users started asking the same things: "Can I see what other people posted?" "Can I share my favorite with a friend?"
Up to a few thousand DAU, social features are a pleasant add-on. Past that — once retention starts to be measured at 7 and 30 days — the shape of your database, the order of your timeline, and the way you fire notifications stop being technical details and start being the things that decide whether people come back. I once shipped an "off-graph popular posts mixed into the home feed" experiment on one of my wallpaper apps and dropped Day 7 retention from 41% to 28%. It took six months to get it back, and the lessons from that recovery are baked into this guide.
This is a hands-on guide to building a social layer in Rork Max with Supabase that doesn't break under load. We'll cover the follow graph, timeline feeds, optimistic likes and comments, real-time updates, and push notifications end-to-end. By the time you finish, you should know exactly which piece of your own app to touch first.
Setup and context
Social features boil down to two questions: how do you preserve the connection between users, and what do you show in the timeline? Twitter took off because of the follow model and Instagram pulled ahead of Facebook because of a clean follow-only photo feed. Both made the deliberate choice to keep the home timeline pure.
What's harder to copy is the discipline. Most "social app v1" implementations end up technically working but visually noisy — every feature on, every notification firing, every user a little bit unhappy. The shortcut I keep coming back to is to decide what not to build at each layer (database, timeline ordering, notifications) before you decide what to build. That decision is what makes the result feel kind to your users.
The chapters below walk through database design, follow graph, timeline feed, optimistic likes/comments, real-time sync, infinite scroll, and push notifications, in that order, with Supabase as the backend.
Database Design — The Foundation of Everything
Your schema design determines whether your social app scales to millions of users or breaks at thousands. Let's build it right from the start.
Table Structure
profiles
├── id (UUID, PK)
├── user_id (UUID, FK → auth.users)
├── username (TEXT, UNIQUE)
├── display_name (TEXT)
├── avatar_url (TEXT)
├── bio (TEXT)
├── followers_count (INT)
├── following_count (INT)
├── created_at (TIMESTAMP)
└── updated_at (TIMESTAMP)
follows
├── id (UUID, PK)
├── follower_id (UUID, FK → profiles)
├── following_id (UUID, FK → profiles)
├── created_at (TIMESTAMP)
└── UNIQUE(follower_id, following_id)
posts
├── id (UUID, PK)
├── author_id (UUID, FK → profiles)
├── content (TEXT)
├── image_urls (TEXT[])
├── likes_count (INT)
├── comments_count (INT)
├── created_at (TIMESTAMP)
├── updated_at (TIMESTAMP)
└── deleted_at (TIMESTAMP, soft delete)
likes
├── id (UUID, PK)
├── post_id (UUID, FK → posts)
├── user_id (UUID, FK → profiles)
├── created_at (TIMESTAMP)
└── UNIQUE(post_id, user_id)
comments
├── id (UUID, PK)
├── post_id (UUID, FK → posts)
├── author_id (UUID, FK → profiles)
├── parent_comment_id (UUID, FK → comments, nullable)
├── content (TEXT)
├── likes_count (INT)
├── created_at (TIMESTAMP)
├── updated_at (TIMESTAMP)
└── deleted_at (TIMESTAMP)
notifications
├── id (UUID, PK)
├── user_id (UUID, FK → profiles)
├── type (TEXT: 'like', 'comment', 'follow')
├── triggered_by_id (UUID, FK → profiles)
├── post_id (UUID, FK → posts, nullable)
├── comment_id (UUID, FK → comments, nullable)
├── is_read (BOOLEAN)
├── created_at (TIMESTAMP)
└── INDEX(user_id, is_read)
Supabase Implementation
Run this in the Supabase SQL Editor:
-- profiles tableCREATE TABLE profiles ( id UUID PRIMARY KEY DEFAULT auth.uid(), user_id UUID REFERENCES auth.users NOT NULL, username TEXT UNIQUE NOT NULL, display_name TEXT, avatar_url TEXT, bio TEXT, followers_count INT DEFAULT 0, following_count INT DEFAULT 0, created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now());-- Enable RLSALTER TABLE profiles ENABLE ROW LEVEL SECURITY;CREATE POLICY "Public profiles are viewable by all" ON profiles FOR SELECT USING (true);CREATE POLICY "Users can update their own profile" ON profiles FOR UPDATE USING (auth.uid() = id);-- follows tableCREATE TABLE follows ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), follower_id UUID REFERENCES profiles NOT NULL, following_id UUID REFERENCES profiles NOT NULL, created_at TIMESTAMP DEFAULT now(), UNIQUE(follower_id, following_id), CHECK (follower_id != following_id));ALTER TABLE follows ENABLE ROW LEVEL SECURITY;CREATE POLICY "Follows are public" ON follows FOR SELECT USING (true);CREATE POLICY "Users can manage their follows" ON follows FOR INSERT WITH CHECK (auth.uid() = follower_id);CREATE POLICY "Users can unfollow" ON follows FOR DELETE USING (auth.uid() = follower_id);-- posts tableCREATE TABLE posts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), author_id UUID REFERENCES profiles NOT NULL, content TEXT NOT NULL, image_urls TEXT[], likes_count INT DEFAULT 0, comments_count INT DEFAULT 0, created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now(), deleted_at TIMESTAMP);ALTER TABLE posts ENABLE ROW LEVEL SECURITY;CREATE POLICY "Public posts are viewable" ON posts FOR SELECT USING (deleted_at IS NULL);CREATE POLICY "Users can create posts" ON posts FOR INSERT WITH CHECK (auth.uid() = author_id);CREATE POLICY "Users can update own posts" ON posts FOR UPDATE USING (auth.uid() = author_id);-- likes tableCREATE TABLE likes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), post_id UUID REFERENCES posts NOT NULL, user_id UUID REFERENCES profiles NOT NULL, created_at TIMESTAMP DEFAULT now(), UNIQUE(post_id, user_id));ALTER TABLE likes ENABLE ROW LEVEL SECURITY;CREATE POLICY "Likes are public" ON likes FOR SELECT USING (true);CREATE POLICY "Users can like" ON likes FOR INSERT WITH CHECK (auth.uid() = user_id);CREATE POLICY "Users can unlike" ON likes FOR DELETE USING (auth.uid() = user_id);-- comments tableCREATE TABLE comments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), post_id UUID REFERENCES posts NOT NULL, author_id UUID REFERENCES profiles NOT NULL, parent_comment_id UUID REFERENCES comments, content TEXT NOT NULL, likes_count INT DEFAULT 0, created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now(), deleted_at TIMESTAMP);ALTER TABLE comments ENABLE ROW LEVEL SECURITY;CREATE POLICY "Comments are public" ON comments FOR SELECT USING (deleted_at IS NULL);CREATE POLICY "Users can comment" ON comments FOR INSERT WITH CHECK (auth.uid() = author_id);CREATE POLICY "Users can update own comments" ON comments FOR UPDATE USING (auth.uid() = author_id);-- notifications tableCREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES profiles NOT NULL, type TEXT CHECK (type IN ('like', 'comment', 'follow')), triggered_by_id UUID REFERENCES profiles NOT NULL, post_id UUID REFERENCES posts, comment_id UUID REFERENCES comments, is_read BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT now());ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;CREATE POLICY "Users can view own notifications" ON notifications FOR SELECT USING (auth.uid() = user_id);CREATE POLICY "Users can mark as read" ON notifications FOR UPDATE USING (auth.uid() = user_id);-- Create indexes for performanceCREATE INDEX idx_profiles_username ON profiles(username);CREATE INDEX idx_follows_follower_id ON follows(follower_id);CREATE INDEX idx_follows_following_id ON follows(following_id);CREATE INDEX idx_posts_author_id ON posts(author_id);CREATE INDEX idx_posts_created_at ON posts(created_at DESC);CREATE INDEX idx_likes_post_id ON likes(post_id);CREATE INDEX idx_likes_user_id ON likes(user_id);CREATE INDEX idx_comments_post_id ON comments(post_id);CREATE INDEX idx_comments_author_id ON comments(author_id);CREATE INDEX idx_notifications_user_id ON notifications(user_id, is_read);
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Design judgments that kept social features from breaking at scale (no off-graph mixing, when not to denormalize follower_count, why LWW is enough for likes) — drawn from real production operation
✦A staged-fallback strategy for Supabase Realtime before you hit the ~10k concurrent-connection wall, with retry and invalid-notification suppression code
✦Push-notification segmentation thresholds that actually move OPEN rate, plus a runbook for recovering from a misfire
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The best mobile apps feel responsive. When a user taps the like button, the heart should turn red immediately, not after the network request completes. This is optimistic updating.
Notifications bring users back. When a friend follows them or likes their post, a well-timed notification can reignite engagement.
Firebase Cloud Messaging Setup
// services/notificationService.tsimport messaging from '@react-native-firebase/messaging';class NotificationService { private supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY! ); async initializeNotifications(userId: string) { // Get FCM token const fcmToken = await messaging().getToken(); // Store FCM token in Supabase await this.supabase .from('user_devices') .upsert({ user_id: userId, fcm_token: fcmToken, platform: 'ios', updated_at: new Date() }); // Foreground notification handler messaging().onMessage(async (remoteMessage) => { console.log('Foreground notification:', remoteMessage); this.handleNotification(remoteMessage); }); // Background notification handler messaging().onNotificationOpenedApp((remoteMessage) => { console.log('App opened from notification:', remoteMessage); this.navigateToNotificationContent(remoteMessage); }); } private handleNotification(remoteMessage: any) { // Display notification locally } private navigateToNotificationContent(remoteMessage: any) { const { notificationType, targetId } = remoteMessage.data; switch (notificationType) { case 'like': case 'comment': // Navigate to post break; case 'follow': // Navigate to user profile break; } }}export const notificationService = new NotificationService();
Six things the docs won't tell you — lessons from production
Now that the building blocks are in place, here are six judgment calls I picked up from running these indie apps in production. None of them are in the Supabase or FCM docs. Each one is something I had to walk back from after shipping it the "obvious" way first.
1. Mixing off-graph "popular" posts into the home feed drops Day 7 retention
Plenty of growth-team writeups suggest that injecting 20% off-graph popular content into the home timeline at low DAU drives engagement. I believed it, shipped it on a wallpaper app with WHERE user_id IN (followees) UNION ALL WHERE popularity_score > X LIMIT 5, and watched Day 7 retention drop from 41% to 28%.
The reason is simple: users come to the home feed to see the people they followed. Off-graph content reads as noise — "my feed is dirty now". Instagram's early team made the same call (a strictly follow-derived feed) for the same reason.
My operating rule now is: never pollute the follow-derived feed. Discovery lives in a separate tab.
// services/timelineService.ts// ❌ Do not do this — mixing discovery into the home feedasync function getMixedHomeFeed(userId: string, cursor?: string) { const { data: followFeed } = await supabase .from('posts') .select('*') .in('user_id', await getFollowees(userId)) .order('created_at', { ascending: false }) .limit(15); // These five rows are what drop Day 7 const { data: discovery } = await supabase .from('posts') .select('*') .gt('popularity_score', 50) .order('popularity_score', { ascending: false }) .limit(5); return [...(followFeed ?? []), ...(discovery ?? [])];}// ✅ Recommended — home feed is pure; discovery is its own screenasync function getHomeFeed(userId: string, cursor?: string) { const followees = await getFollowees(userId); if (followees.length === 0) { return { posts: [], suggestions: await getFollowSuggestions(userId) }; } const query = supabase .from('posts') .select('*, profiles!inner(*)') .in('user_id', followees) .order('created_at', { ascending: false }) .limit(20); if (cursor) query.lt('created_at', cursor); const { data } = await query; return { posts: data ?? [], suggestions: null };}
If you want users to discover new accounts, put it in a separate tab or pin a small "people you might follow" card above the feed, never inside it.
2. You'll want to denormalize follower_count — resist for the first year
The urge to store profiles.follower_count arrives faster than you'd think, because SELECT COUNT(*) FROM follows WHERE followee_id = ?feels slow on a profile screen.
In practice, with a B-tree index on (followee_id, created_at DESC), that count returns in under 10ms up to about 50k DAU and 2M total follow rows. Denormalizing instead means every follow/unfollow triggers UPDATE profiles SET follower_count = follower_count ± 1 — and now you've got transaction contention, cache drift, and retention-metric inconsistencies to chase.
My current rule: count for the first year. When you genuinely outgrow it, switch to a materialized view refreshed every 5 minutes via pg_cron, not to inline updates:
-- migrations/202605_followers_view.sqlCREATE MATERIALIZED VIEW user_follower_stats ASSELECT followee_id AS user_id, COUNT(*) AS follower_count, COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days') AS new_followers_7dFROM followsGROUP BY followee_id;CREATE UNIQUE INDEX ON user_follower_stats (user_id);SELECT cron.schedule( 'refresh_user_follower_stats', '*/5 * * * *', $$REFRESH MATERIALIZED VIEW CONCURRENTLY user_follower_stats$$);
REFRESH MATERIALIZED VIEW CONCURRENTLY doesn't block readers, which is what you want on a profile screen. The unique index on user_id is required for CONCURRENTLY to be legal.
3. Likes don't need CRDTs — LWW with a unique constraint is enough
At some point in designing social features, you'll wonder whether likes deserve CRDTs because the operation is commutative. I built it that way with Yjs once.
In production, like/unlike is fine as idempotent LWW (last-writer-wins) for three reasons:
One user double-tapping the same post from two devices in the same millisecond essentially doesn't happen
If it did, the user expects "the state of the button I pressed last" — which is what LWW gives them
CRDT counting is expensive: counting a CRDT set means the server enumerates members
What I run in production on wallpaper apps is a Supabase unique constraint plus the server timestamp:
-- migrations/202605_likes_table.sqlCREATE TABLE likes ( id BIGSERIAL PRIMARY KEY, user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (user_id, post_id));CREATE INDEX likes_post_id_created_idx ON likes (post_id, created_at DESC);
The UNIQUE (user_id, post_id) constraint is what guarantees "LWW + idempotent" for free, even if two devices fire at exactly the same moment. The same call applies to comment edits — if you need history, build a separate comment_revisions table; don't reach for CRDTs.
4. Build a staged fallback before Supabase Realtime hits ~10k concurrent
Supabase Realtime is great until you bump into the concurrent-connection ceiling on Free / Pro (around 10k across channels × clients). On one of my calming-app titles, I started seeing Realtime delays of 8–30 seconds at the 23:00 JST peak around 250k MAU.
You don't need to abandon WebSockets — you need a way to demote to polling in stages. This is what I run:
The key idea: don't swallow Realtime errors silently — promote them to a documented demotion path. In my deployment, demotion to polling-30s is invisible to about 90% of users. Demotion to polling-2m starts generating "feed is stale" complaints, so at that level I route the freshness signal through push notifications instead.
5. Decide which push notifications "earn their spot" by OPEN rate
Once social is wired up, the temptation is to add notification types — follow, like, comment, reply, mention, tag, group invite, all on by default. My first cut had seven types enabled. OPEN rate fell below 4%, and at the three-month mark 38% of users had turned notifications off entirely.
The rule I run now: if a notification type's OPEN rate is below 8%, kill it server-side.
// supabase/functions/send-social-notifications/index.tsconst OPEN_RATE_THRESHOLD = 0.08; // 8%const NOTIFICATION_TYPES = ['follow', 'like', 'comment', 'reply', 'mention', 'tag', 'group_invite'] as const;async function shouldSendNotification( type: typeof NOTIFICATION_TYPES[number], recipientUserId: string): Promise<boolean> { const { data } = await supabase.rpc('get_notification_open_rate', { notification_type: type, since_days: 30, }); if ((data?.open_rate ?? 1) < OPEN_RATE_THRESHOLD) { return false; } const { data: prefs } = await supabase .from('notification_preferences') .select('disabled_types') .eq('user_id', recipientUserId) .single(); if (prefs?.disabled_types?.includes(type)) return false; // Burst suppression: stop if we already sent 3+ of this type in the last 30 minutes const { count } = await supabase .from('notification_log') .select('*', { count: 'exact', head: true }) .eq('user_id', recipientUserId) .eq('type', type) .gt('sent_at', new Date(Date.now() - 30 * 60_000).toISOString()); return (count ?? 0) < 3;}
In my titles, only follow and mention reliably hold OPEN rates above 12% over time. tag and group_invite typically die out within five months. "More notification types = more engagement" turns out to be a myth — what actually happens is that more types lower your permission grant rate, and the few notifications that do matter stop landing.
6. Write the "we sent it to everyone" recovery runbook before you need it
The scariest social-feature incident isn't a slow query — it's "we accidentally sent the same notification to everyone" or "the timeline went empty for all users." I once forgot a WHERE user_id IS NOT NULL clause in a Cron-driven SQL job and sent "you have a new comment" to 250k people.
What I took from that day: the recovery plan belongs in the system requirements from day one, not in a postmortem.
Before any bulk notification dispatch, post the projected recipient count to a Slack webhook. Anything over 10,000 requires a manual approval click.
Keep an EMERGENCY_NOTIFICATION_KILL_SWITCH env var on the Edge Function. When true, the function refuses to send anything.
Store a notification_circuit_breaker flag in KV. If error rate over the last 5 minutes exceeds 5%, the function opens the breaker automatically.
Pre-write an in-app apology message template into app/incidents/templates/. When the next incident hits, you will not have time to compose one.
You don't need anything fancier. Trusting "the version of me that already wired the kill switch" is the cheapest insurance an indie developer can buy for a social product.
A single next step
Production-grade social features come down to deciding what not to build at each layer — database, timeline, real-time, notifications. The patterns in this guide are the minimum configuration I'd land on today, reflecting both what worked and what I had to walk back across years of production operation.
If you only do one thing tomorrow, check whether your home feed and your discovery surface live on the same screen. If they do, splitting them into separate tabs is the single change most likely to move Day 7 retention by a few percentage points. That's the biggest single retention improvement I've ever shipped to a social feature.
I'm still working out how social fits onto my own wallpaper apps — quietly, one experiment at a time. If any of this is useful for an app you're building, I'd be glad. Thanks for reading.
Further Reading
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.