Best Boilerplates for Building Social Apps 2026
·StarterPick Team
social-appreal-timesupabasestreampusherboilerplate2026
TL;DR
Building a social app means solving the social graph, real-time feeds, and notifications — three fundamentally different problems. No single boilerplate handles all three well. The 2026 stack: Supabase (database + auth + realtime) for the core, Stream (feed infrastructure) or custom fan-out for production-scale feeds, and Pusher/Ably for real-time notifications. For MVP: Supabase realtime subscriptions handle everything. For scale: Stream Feed API.
Key Takeaways
- Social graph: Postgres self-join (
followstable) is the right approach for most apps - Feed: Fan-out on write (push to followers) vs fan-out on read (pull at load time)
- Stream Feed API: Managed feed infrastructure for 10M+ activity items
- Supabase Realtime: Good for MVP, channel-based subscriptions
- Notifications: Supabase row-level subscriptions or Pusher for client push
- Media uploads: Supabase Storage or Cloudflare R2
The Social App Data Model
-- Minimal social graph schema:
CREATE TABLE profiles (
id UUID REFERENCES auth.users(id) PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT,
bio TEXT,
avatar_url TEXT,
followers_count INTEGER DEFAULT 0,
following_count INTEGER DEFAULT 0,
posts_count INTEGER DEFAULT 0
);
CREATE TABLE follows (
follower_id UUID REFERENCES profiles(id),
following_id UUID REFERENCES profiles(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (follower_id, following_id)
);
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES profiles(id) NOT NULL,
content TEXT NOT NULL,
media_urls TEXT[],
likes_count INTEGER DEFAULT 0,
replies_count INTEGER DEFAULT 0,
reposts_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE likes (
user_id UUID REFERENCES profiles(id),
post_id UUID REFERENCES posts(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, post_id)
);
CREATE TABLE notifications (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES profiles(id) NOT NULL,
actor_id UUID REFERENCES profiles(id) NOT NULL,
type TEXT NOT NULL, -- 'like', 'follow', 'reply', 'mention'
post_id UUID REFERENCES posts(id),
read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS policies:
ALTER TABLE follows ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can see all follows" ON follows FOR SELECT USING (true);
CREATE POLICY "Users can follow others" ON follows FOR INSERT WITH CHECK (auth.uid() = follower_id);
CREATE POLICY "Users can unfollow" ON follows FOR DELETE USING (auth.uid() = follower_id);
Follow/Unfollow with Supabase
// hooks/useSocial.ts:
import { useSupabaseClient } from '@supabase/auth-helpers-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useFollow(targetUserId: string) {
const supabase = useSupabaseClient();
const queryClient = useQueryClient();
const follow = useMutation({
mutationFn: async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
// Insert follow relationship:
await supabase.from('follows').insert({
follower_id: user.id,
following_id: targetUserId,
});
// Create notification for the target user:
await supabase.from('notifications').insert({
user_id: targetUserId,
actor_id: user.id,
type: 'follow',
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile', targetUserId] });
queryClient.invalidateQueries({ queryKey: ['is-following', targetUserId] });
},
});
const unfollow = useMutation({
mutationFn: async () => {
const { data: { user } } = await supabase.auth.getUser();
await supabase.from('follows')
.delete()
.eq('follower_id', user!.id)
.eq('following_id', targetUserId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile', targetUserId] });
},
});
return { follow, unfollow };
}
Activity Feed
// Simple fan-out on read (good for MVPs):
// Fetch posts from people the current user follows
export async function getFeed(userId: string, page = 0) {
const { data } = await supabase
.from('posts')
.select(`
*,
profiles (username, display_name, avatar_url),
likes (user_id)
`)
.in('user_id',
// Subquery: get all users this person follows
supabase.from('follows')
.select('following_id')
.eq('follower_id', userId)
)
.order('created_at', { ascending: false })
.range(page * 20, (page + 1) * 20 - 1);
return data;
}
// Or with a SQL function for performance:
// CREATE FUNCTION get_feed(p_user_id UUID, p_limit INT, p_offset INT)
// RETURNS SETOF posts AS $$
// SELECT p.* FROM posts p
// JOIN follows f ON p.user_id = f.following_id
// WHERE f.follower_id = p_user_id
// ORDER BY p.created_at DESC
// LIMIT p_limit OFFSET p_offset;
// $$ LANGUAGE sql;
Real-Time Feed with Supabase
// components/Feed.tsx — real-time new posts:
'use client';
import { useEffect, useState } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
export function Feed({ userId }: { userId: string }) {
const [newPosts, setNewPosts] = useState<Post[]>([]);
const supabase = createClientComponentClient();
useEffect(() => {
// Subscribe to new posts from followed users:
const channel = supabase
.channel('feed-updates')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'posts',
},
(payload) => {
// Check if this is from someone we follow:
checkIfFollowing(payload.new.user_id).then((isFollowing) => {
if (isFollowing) {
setNewPosts((prev) => [payload.new as Post, ...prev]);
}
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
return (
<div>
{newPosts.length > 0 && (
<button className="w-full bg-blue-50 text-blue-600 py-2 rounded-lg mb-4"
onClick={() => setNewPosts([])}>
{newPosts.length} new post{newPosts.length > 1 ? 's' : ''}
</button>
)}
<PostList userId={userId} prependPosts={newPosts} />
</div>
);
}
Stream Feed: Production Scale
npm install getstream
// For high-volume social apps (Twitter-scale):
import { connect } from 'getstream';
const client = connect(
process.env.STREAM_API_KEY!,
process.env.STREAM_API_SECRET!,
process.env.STREAM_APP_ID!
);
// Add a post to the user's feed:
async function addPost(userId: string, post: { content: string; mediaUrls: string[] }) {
const userFeed = client.feed('user', userId);
await userFeed.addActivity({
actor: `SU:${userId}`,
verb: 'post',
object: post.id,
content: post.content,
media_urls: post.mediaUrls,
time: new Date().toISOString(),
});
}
// Get timeline (aggregated feed of followed users):
async function getTimeline(userId: string, limit = 20, offset = 0) {
const timeline = client.feed('timeline', userId);
const { results } = await timeline.get({ limit, offset });
return results;
}
// Follow/unfollow:
async function followUser(follower: string, target: string) {
const followerTimeline = client.feed('timeline', follower);
await followerTimeline.follow('user', target);
}
Decision Guide
Use Supabase Realtime for social if:
→ MVP / early stage (< 10K users)
→ Budget constrained
→ Team knows SQL well
→ Simple feed (chronological, not algorithmic)
Use Stream Feed if:
→ Need to scale to millions of activities
→ Need aggregated feeds (notifications digest)
→ Want fan-out handled automatically
→ Enterprise features (analytics, moderation)
Use Pusher/Ably for notifications if:
→ Need client-push (user gets ping, no polling)
→ Native mobile app notifications
→ Real-time typing indicators
→ Supabase realtime isn't granular enough
Avoid building from scratch if:
→ You're implementing feed fan-out yourself
→ Writing your own social graph traversal SQL
→ Building your own notification delivery system
→ Focus on your product, not infrastructure
Find social app boilerplates at StarterPick.