Build a social media feed in Lovable with posts, likes, comments, follows, and cursor-based pagination. Supabase Realtime delivers new posts at the top of the feed without a page refresh. The feed query uses a follows-based filter so users see only content from people they follow, with infinite scroll loading older posts on demand.
What you're building
A social feed needs to solve three performance challenges: feed query efficiency, pagination stability, and real-time delivery without disrupting scroll position.
The feed query uses a join through the follows table: SELECT posts.* FROM posts INNER JOIN follows ON follows.followee_id = posts.user_id WHERE follows.follower_id = auth.uid() ORDER BY posts.created_at DESC. With an index on posts(user_id, created_at DESC) and follows(follower_id), this query stays fast even with millions of posts.
Cursor-based pagination uses the created_at of the oldest visible post as the cursor: WHERE posts.created_at < cursor ORDER BY created_at DESC LIMIT 10. This is stable — new posts arriving at the top do not shift offset-based page numbers. You always load the next 10 posts before the current oldest one regardless of what is happening at the top of the feed.
Realtime new posts subscribe to INSERT events on posts filtered by users the current user follows. When a followed user publishes, the new post appears at the top of the feed. You track whether the user has scrolled down. If they have, show a 'N new posts' badge at the top instead of auto-scrolling, exactly like Twitter/X behavior.
Final result
A fully functional social media feed with personalized content, infinite scroll, real-time new posts, likes, comments, follows, and profile pages.
Tech stack
Prerequisites
- Lovable Pro account with Supabase Realtime access
- Supabase project with URL and anon key in Cloud tab → Secrets
- Supabase Auth with at least two test accounts for follow and feed testing
- Supabase Storage bucket for post image uploads (optional but covered in the guide)
- Basic understanding of cursor-based pagination vs offset-based pagination
Build steps
Create the social feed schema
Set up all tables for the social platform. The key design decisions are: denormalizing like_count on posts for fast feed rendering, using a parent_id on comments for threading, and indexing follows(follower_id) for the feed query.
1Create a social media feed schema in Supabase. Tables:231. profiles: id (references auth.users), username (text unique), display_name (text), bio (text), avatar_url (text), follower_count (int default 0), following_count (int default 0), post_count (int default 0), created_at452. posts: id, user_id (references auth.users), body (text, max 280 chars via CHECK), image_url (text nullable), like_count (int default 0), comment_count (int default 0), created_at673. likes: id, post_id (references posts), user_id (references auth.users), created_at, UNIQUE(post_id, user_id)894. comments: id, post_id (references posts), user_id (references auth.users), parent_id (references comments, nullable), body (text), created_at10115. follows: id, follower_id (references auth.users), followee_id (references auth.users), created_at, UNIQUE(follower_id, followee_id), CHECK(follower_id != followee_id)1213RLS:14- profiles: public SELECT, own UPDATE15- posts: public SELECT, own INSERT/UPDATE/DELETE16- likes: public SELECT, authenticated INSERT/DELETE own rows17- comments: public SELECT, authenticated INSERT, own DELETE18- follows: public SELECT, own INSERT/DELETE1920Indexes:21- posts(user_id, created_at DESC)22- follows(follower_id, followee_id)23- likes(post_id, user_id)24- comments(post_id, parent_id, created_at)2526Triggers:27- After INSERT on likes: UPDATE posts SET like_count = like_count + 128- After DELETE on likes: UPDATE posts SET like_count = like_count - 129- After INSERT on comments: UPDATE posts SET comment_count = comment_count + 130- After INSERT on follows: UPDATE profiles SET follower_count = follower_count + 1 WHERE id = followee_id; UPDATE profiles SET following_count = following_count + 1 WHERE id = follower_id31- After DELETE on follows: decrement both countsPro tip: Ask Lovable to also create a get_feed(p_user_id uuid, p_cursor timestamptz, p_limit int) Postgres function. It joins posts through follows, applies the cursor filter, and returns posts joined with the author's profile in one query. Calling one RPC is faster than two separate Supabase queries.
Expected result: All five tables created with indexes, RLS, and denormalization triggers. The like_count increments automatically when a like row is inserted. TypeScript types generated.
Build the feed with cursor-based infinite scroll
Create the feed query with cursor pagination and an Intersection Observer that triggers loading more posts when the user reaches the bottom of the feed.
1// src/hooks/useFeed.ts2import { useCallback, useEffect, useRef, useState } from 'react'3import { supabase } from '@/lib/supabase'4import { useAuth } from '@/hooks/useAuth'56type Post = {7 id: string8 user_id: string9 body: string10 image_url: string | null11 like_count: number12 comment_count: number13 created_at: string14 author: { username: string; display_name: string; avatar_url: string | null }15 viewer_has_liked?: boolean16}1718const PAGE_SIZE = 101920export function useFeed() {21 const { user } = useAuth()22 const [posts, setPosts] = useState<Post[]>([])23 const [loading, setLoading] = useState(false)24 const [hasMore, setHasMore] = useState(true)25 const [newPostCount, setNewPostCount] = useState(0)26 const cursor = useRef<string | undefined>()27 const bottomRef = useRef<HTMLDivElement>(null)2829 const fetchPage = useCallback(async (after?: string) => {30 if (!user?.id || loading) return31 setLoading(true)32 const { data } = await supabase.rpc('get_feed', {33 p_user_id: user.id,34 p_cursor: after ?? new Date().toISOString(),35 p_limit: PAGE_SIZE,36 })37 const rows = (data ?? []) as Post[]38 if (after) {39 setPosts((prev) => [...prev, ...rows])40 } else {41 setPosts(rows)42 }43 cursor.current = rows[rows.length - 1]?.created_at44 setHasMore(rows.length === PAGE_SIZE)45 setLoading(false)46 }, [user?.id, loading])4748 useEffect(() => { fetchPage() }, [user?.id])4950 // Infinite scroll via Intersection Observer51 useEffect(() => {52 if (!bottomRef.current) return53 const observer = new IntersectionObserver(54 (entries) => { if (entries[0].isIntersecting && hasMore && !loading) fetchPage(cursor.current) },55 { threshold: 0.1 }56 )57 observer.observe(bottomRef.current)58 return () => observer.disconnect()59 }, [hasMore, loading, fetchPage])6061 // Realtime new posts62 useEffect(() => {63 if (!user?.id) return64 const channel = supabase65 .channel(`feed:${user.id}`)66 .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, () => {67 setNewPostCount((n) => n + 1)68 })69 .subscribe()70 return () => { supabase.removeChannel(channel) }71 }, [user?.id])7273 const loadNewPosts = async () => {74 setNewPostCount(0)75 await fetchPage()76 }7778 return { posts, loading, hasMore, newPostCount, loadNewPosts, bottomRef }79}Pro tip: The Realtime subscription fires for ALL post inserts, not just followed users. On the server side in the get_feed RPC, filter by follows. On the Realtime side, simply increment the newPostCount badge for any new post — the load will filter correctly. Avoid complex client-side follow filtering in the Realtime callback.
Expected result: The feed loads 10 posts on mount. Scrolling to the bottom loads the next 10. New posts from followed users show a 'N new posts' badge at the top.
Build the post card with like and comment actions
Create the PostCard component showing the author avatar, post body, image preview, like button with count, comment button, and share button. Like state is tracked locally for instant feedback.
1Build a PostCard component at src/components/PostCard.tsx.23Requirements:4- Card layout with: Avatar + display_name + username + relative timestamp in the header5- Post body text (break-words, whitespace-pre-wrap to respect line breaks)6- If image_url exists: a full-width rounded image below the body (max-h-96 object-cover)7- Action row at the bottom:8 - Heart Button: filled red if viewer_has_liked, outline if not. Shows like_count. On click: optimistically toggle liked state and increment/decrement count, then call the Supabase INSERT or DELETE on likes.9 - Comment Button: shows comment_count, clicking expands a CommentSection below the card10 - Share Button: copies the post URL to clipboard and shows a brief 'Copied!' Toast11- CommentSection (collapsible):12 - Show last 3 comments with avatar, username, body13 - 'View all N comments' link if comment_count > 314 - Comment input at the bottom: Textarea + Submit Button15 - On comment submit: INSERT into comments, update local comment_count optimistically16- PostCard takes a single post prop of type Post plus an onLikeToggle callback17- Use React.memo to prevent re-renders when unrelated posts in the list updatePro tip: Implement likes with optimistic updates: flip the local like state and count immediately on click, then fire the Supabase upsert/delete in the background. If the database call fails, revert the local state. This makes the like action feel instant even on slow connections.
Expected result: Post cards render with author info, body, and image. Clicking the heart button toggles like state instantly. Comments expand inline. Submitting a comment adds it to the list immediately.
Build the compose post form and image upload
Add the post composition area at the top of the feed. Support text up to 280 characters with a live character counter, optional image upload to Supabase Storage, and a submit button that inserts the post and prepends it to the feed.
1Build a ComposePost component at src/components/ComposePost.tsx.23Requirements:4- Card with the user's Avatar, a Textarea (placeholder: 'What's on your mind?'), and a footer row5- Character counter: shows '280' counting down. Text turns amber at 50 remaining, red at 20 remaining. Disable submit when 0.6- Image button: file input (accept='image/*') hidden, triggered by a camera icon Button. Shows thumbnail preview with an 'x' remove button.7- Submit Button: disabled when body is empty or uploading. Shows a spinner during submission.8- On submit:9 1. If image selected: upload to Supabase Storage at path: posts/{userId}/{Date.now()}.{ext}. Get public URL.10 2. INSERT into posts: { user_id, body, image_url }11 3. On success: clear the form, optimistically prepend the new post to the feed list12 4. On error: show a Toast with the error message13- Use react-hook-form + zod for validation: body min 1 char, max 280 chars14- After insert: call supabase.from('profiles').update({ post_count: increment }) — or handle this via a Postgres triggerPro tip: Prepend the new post to the feed list optimistically before the INSERT confirms. Include a temporary 'posting...' status field on the optimistic post object. Replace it with the real post data when the INSERT resolves. If it fails, remove the optimistic post and show an error.
Expected result: The compose area renders at the top of the feed. Typing shows the character countdown. Uploading an image shows a thumbnail preview. Submitting inserts the post and it appears at the top of the feed immediately.
Build the follow system and profile pages
Create profile pages showing a user's posts, follower count, and following count. Add a follow/unfollow button that updates the follows table and triggers the denormalization counters via database triggers.
1Build user profile pages at src/pages/Profile.tsx.23Requirements:4- Fetch profile by username from URL param: /profile/:username5- Profile header:6 - Large Avatar (96px)7 - Display name and @username8 - Bio text9 - Stats row: '{post_count} Posts', '{follower_count} Followers', '{following_count} Following' — each clickable opening a Sheet with the user list10 - Follow/Unfollow Button: show 'Follow' if not following, 'Following' (with hover 'Unfollow') if following. On click: INSERT or DELETE from follows.11 - If viewing own profile: replace Follow button with 'Edit Profile' Button opening a Dialog12- Posts grid below the header: a grid of post cards filtered to this profile's posts, ordered by created_at DESC13- Edit Profile Dialog: form for display_name, bio, and avatar upload. On save: UPDATE profiles.14- useFollowStatus hook: checks if auth.uid() follows this profile_id via supabase.from('follows').select().eq('follower_id', userId).eq('followee_id', profileId).maybeSingle()15- Optimistic follow toggle: flip local state on click, then fire the Supabase call in the backgroundPro tip: Add a 'Suggested users to follow' section on the feed page for new users who follow nobody. Query profiles where the user is NOT already following them and order by follower_count DESC. Show 3-5 suggestions as Avatar + name + follow Button. This is the most important onboarding feature for any social platform.
Expected result: Profile pages show the user's posts and stats. Clicking Follow inserts into follows and the follower_count Badge increments immediately via the trigger. Unfollowing decrements it.
Complete code
1import { useCallback, useEffect, useRef, useState } from 'react'2import { supabase } from '@/lib/supabase'3import { useAuth } from '@/hooks/useAuth'45export type Post = {6 id: string; user_id: string; body: string; image_url: string | null7 like_count: number; comment_count: number; created_at: string8 viewer_has_liked: boolean9 author: { username: string; display_name: string; avatar_url: string | null }10}1112const PAGE_SIZE = 101314export function useFeed() {15 const { user } = useAuth()16 const [posts, setPosts] = useState<Post[]>([])17 const [loading, setLoading] = useState(false)18 const [hasMore, setHasMore] = useState(true)19 const [newPostCount, setNewPostCount] = useState(0)20 const cursorRef = useRef<string | undefined>()21 const loadingRef = useRef(false)22 const bottomRef = useRef<HTMLDivElement>(null)2324 const fetchPage = useCallback(async (cursorTs?: string) => {25 if (!user?.id || loadingRef.current) return26 loadingRef.current = true; setLoading(true)27 const { data, error } = await supabase.rpc('get_feed', {28 p_user_id: user.id, p_cursor: cursorTs ?? new Date().toISOString(), p_limit: PAGE_SIZE,29 })30 if (!error) {31 const rows = (data ?? []) as Post[]32 cursorTs ? setPosts(prev => [...prev, ...rows]) : setPosts(rows)33 if (rows.length > 0) cursorRef.current = rows[rows.length - 1].created_at34 setHasMore(rows.length === PAGE_SIZE)35 }36 loadingRef.current = false; setLoading(false)37 }, [user?.id])3839 useEffect(() => { fetchPage() }, [user?.id])4041 useEffect(() => {42 if (!bottomRef.current) return43 const observer = new IntersectionObserver(44 entries => { if (entries[0].isIntersecting && hasMore && !loadingRef.current) fetchPage(cursorRef.current) },45 { threshold: 0.1 }46 )47 const el = bottomRef.current48 observer.observe(el)49 return () => observer.unobserve(el)50 }, [hasMore, fetchPage])5152 useEffect(() => {53 if (!user?.id) return54 const channel = supabase.channel(`feed-new-posts:${user.id}`)55 .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, payload => {56 if ((payload.new as Post).user_id !== user.id) setNewPostCount(n => n + 1)57 })58 .subscribe()59 return () => { supabase.removeChannel(channel) }60 }, [user?.id])6162 const loadNewPosts = useCallback(async () => { setNewPostCount(0); await fetchPage() }, [fetchPage])6364 const updatePostLike = useCallback((postId: string, liked: boolean, delta: number) => {65 setPosts(prev => prev.map(p => p.id === postId ? { ...p, viewer_has_liked: liked, like_count: p.like_count + delta } : p))66 }, [])6768 return { posts, loading, hasMore, newPostCount, loadNewPosts, bottomRef, updatePostLike }69}Customization ideas
Trending hashtags
Parse hashtags from post body on INSERT using a Postgres trigger that extracts #word patterns and inserts into a hashtags table (hashtag text, post_id, created_at). Build a trending sidebar that counts hashtag occurrences in the last 24 hours, sorted descending. Clicking a hashtag filters the feed.
Post bookmarks
Add a bookmarks table (user_id, post_id, created_at) and a bookmark icon Button to each PostCard. Clicking bookmarks or unbookmarks the post. Build a Bookmarks page at /bookmarks that queries the user's bookmarked posts via a JOIN. Add a bookmark count Badge to the profile page.
Stories (24-hour ephemeral posts)
Add a stories table with expires_at = now() + interval '24 hours'. Show stories as horizontal Avatar circles at the top of the feed. A Supabase cron job deletes expired stories every hour. Stories support only images (image_url required, body optional). Viewing a story marks it as seen via a story_views table.
Post analytics for creators
Add a post_views table (post_id, viewer_id, viewed_at). Record a view when a post scrolls into the viewport using Intersection Observer. Build a creator analytics page showing impressions per post, like rate (likes/views), top performing posts, and follower growth over time as Recharts charts.
Direct messages from profile pages
Add a 'Message' Button to profile pages that creates or opens a DM conversation. Link to the chat-application build pattern for the full DM implementation. Show unread DM count in the navigation bar alongside the feed notification bell.
Common pitfalls
Pitfall: Using offset-based pagination (OFFSET, LIMIT) for the feed
How to avoid: Use cursor-based pagination with the created_at timestamp of the oldest loaded post as the cursor. The WHERE created_at < cursor query is stable regardless of new posts arriving at the top.
Pitfall: Computing like_count via COUNT(*) in the feed query
How to avoid: Denormalize like_count on the posts table and maintain it via Postgres triggers on INSERT/DELETE of likes. The feed query reads the pre-computed count directly with no extra query.
Pitfall: Auto-scrolling to the top when Realtime delivers new posts
How to avoid: Track whether the user has scrolled below the fold. If so, show a '2 new posts' pill Badge that the user clicks to load new posts and scroll to top. Only auto-scroll if the user is already at the top.
Pitfall: Missing the UNIQUE constraint on follows(follower_id, followee_id)
How to avoid: Add UNIQUE(follower_id, followee_id) to the follows table. Use supabase.from('follows').upsert({...}, { onConflict: 'follower_id,followee_id', ignoreDuplicates: true }) on the client to safely handle double-clicks.
Best practices
- Use cursor-based pagination with created_at as the cursor for all feed queries. Offset-based pagination drifts when new content arrives at the top.
- Denormalize like_count, comment_count, follower_count, and following_count via Postgres triggers. Never compute these with COUNT subqueries in feed queries.
- Gate the Realtime subscription on user?.id. Without this guard, unauthenticated page loads create anonymous subscriptions that waste connection slots.
- Use React.memo on PostCard to prevent re-rendering all posts when the newPostCount badge changes in the parent feed component.
- Implement optimistic updates for likes and follows. Update local state immediately, fire the Supabase mutation in the background, and revert on error. Social actions feel broken when they have visible latency.
- Add a CHECK constraint on posts: CHECK(char_length(body) <= 280). Client-side validation is not enough — enforce limits in the database to prevent abuse via direct API calls.
- Index follows(follower_id) and posts(user_id, created_at DESC) separately. The feed join query hits both indexes and the query planner needs both to avoid full table scans.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a social media feed with Supabase. My feed query joins posts through a follows table to show only content from followed users. I need a cursor-based pagination function in PostgreSQL. The function takes p_user_id, p_cursor (a timestamptz), and p_limit. It should return posts from followed users created before the cursor, joined with the author's profile, and a viewer_has_liked boolean (true if p_user_id has liked that post). Show me the full PostgreSQL function with SECURITY DEFINER and proper use of auth.uid().
Add a post detail page at /post/:postId. Show the full post with all comments in a threaded tree (parent comments at the top level, reply comments indented under them). Load comments with: supabase.from('comments').select().eq('post_id', postId).is('parent_id', null).order('created_at').limit(20) for top-level, then load replies for each visible comment. Add a 'Reply' button under each comment that shows an inline reply form. Support one level of nesting only (replies to replies are flat).
In Supabase, create a trigger on the posts table that fires on INSERT and parses hashtags from the body column. Use a regex: regexp_matches(NEW.body, '#([a-zA-Z0-9_]+)', 'g'). For each match, upsert into a hashtags table (tag text, created_at). Then insert into post_tags (post_id, tag, created_at) for each found tag. This enables hashtag search and trending topics without any application-layer parsing.
Frequently asked questions
How do I make the feed show posts from followed users only?
The get_feed Postgres function joins posts through the follows table: INNER JOIN follows ON follows.followee_id = posts.user_id WHERE follows.follower_id = p_user_id. Only posts whose author has a corresponding row in follows for the current user are returned. For new users with no follows, show a 'Discover' feed of top posts from all users sorted by like_count DESC.
How does cursor-based pagination handle deleted posts?
Cursor-based pagination is not affected by post deletions the way offset pagination is. Your cursor is the created_at timestamp of the last loaded post. Deleting posts between page loads does not change any timestamps, so the next page query (WHERE created_at < cursor) returns the correct next batch. The only visual effect is a gap if a deleted post was visible — which is expected behavior.
Can I add video posts in addition to images?
Yes. Add a video_url column to posts alongside image_url. Upload videos to a Supabase Storage bucket. In the PostCard, detect video_url and render a video element with controls, muted, and autoPlay attributes. For production video delivery, consider transcoding videos via a Mux or Cloudflare Stream integration to support adaptive bitrate streaming.
How do I prevent spam posts or abuse?
Add rate limiting via a Postgres function: before allowing an INSERT into posts, count the user's posts in the last hour. If over 10, reject with a custom error. Also add a reports table where users can flag posts. An admin Edge Function reviews flagged posts and can soft-delete (set is_deleted = true). Add is_deleted to the feed query filter: AND posts.is_deleted = false.
How do I show posts from the current user's own profile in their feed?
The follows-based feed join only returns posts from followed users. Add an OR clause in the get_feed function: WHERE (follows.follower_id = p_user_id OR posts.user_id = p_user_id). This includes the user's own posts in their feed without requiring them to follow themselves.
What is the Realtime subscription filtering strategy for the feed?
The postgres_changes subscription on posts does not support filtering by a subquery (like 'post is from a followed user'). Subscribe to all INSERTs on posts and track the count client-side. When the user loads new posts via the badge, the get_feed RPC applies the follows filter server-side. This is simpler and more reliable than trying to pre-filter in the Realtime subscription.
How do I add a 'You might also like' recommendation section?
Build a recommendation query that finds posts liked by people the current user follows (co-engagement filtering): SELECT posts.* FROM posts INNER JOIN likes AS friend_likes ON friend_likes.post_id = posts.id INNER JOIN follows ON follows.followee_id = friend_likes.user_id WHERE follows.follower_id = auth.uid() AND posts.user_id NOT IN (SELECT followee_id FROM follows WHERE follower_id = auth.uid()) GROUP BY posts.id ORDER BY COUNT(*) DESC LIMIT 5.
Can RapidDev help add an algorithm-based ranked feed instead of chronological?
Yes. A ranked feed scores posts by recency, like velocity (likes per hour), comment count, and follow graph distance from the viewer. RapidDev builds advanced Supabase query functions and Edge Functions for feed ranking, recommendation systems, and content moderation pipelines. Reach out for help building a more sophisticated feed algorithm.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation