Skip to main content
RapidDev - Software Development Agency
how-to-build-lovable2.5–3.5 hours

How to Build a Social Media Feed with Lovable

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'll build

  • Posts table with text content, optional image via Supabase Storage, and an indexed created_at for cursor pagination
  • Likes table with a unique constraint preventing double-likes and a like count denormalized on posts for fast display
  • Comments section per post with a reply thread using a parent_id column
  • Follows junction table powering the personalized feed query (only show posts from followed users)
  • Cursor-based infinite scroll pagination loading 10 posts at a time without offset drift
  • Supabase Realtime subscription that prepends new posts to the top of the feed as they are published
  • Profile pages showing a user's posts, follower count, and following count with follow/unfollow toggle
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate17 min read2.5–3.5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableFrontend + Edge Functions
SupabaseDatabase + Realtime + Auth + Storage
shadcn/uiCard, Avatar, Button, Textarea, Skeleton
Intersection ObserverInfinite scroll trigger

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

1

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.

prompt.txt
1Create a social media feed schema in Supabase. Tables:
2
31. 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_at
4
52. 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_at
6
73. likes: id, post_id (references posts), user_id (references auth.users), created_at, UNIQUE(post_id, user_id)
8
94. comments: id, post_id (references posts), user_id (references auth.users), parent_id (references comments, nullable), body (text), created_at
10
115. follows: id, follower_id (references auth.users), followee_id (references auth.users), created_at, UNIQUE(follower_id, followee_id), CHECK(follower_id != followee_id)
12
13RLS:
14- profiles: public SELECT, own UPDATE
15- posts: public SELECT, own INSERT/UPDATE/DELETE
16- likes: public SELECT, authenticated INSERT/DELETE own rows
17- comments: public SELECT, authenticated INSERT, own DELETE
18- follows: public SELECT, own INSERT/DELETE
19
20Indexes:
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)
25
26Triggers:
27- After INSERT on likes: UPDATE posts SET like_count = like_count + 1
28- After DELETE on likes: UPDATE posts SET like_count = like_count - 1
29- After INSERT on comments: UPDATE posts SET comment_count = comment_count + 1
30- 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_id
31- After DELETE on follows: decrement both counts

Pro 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.

2

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.

src/hooks/useFeed.ts
1// src/hooks/useFeed.ts
2import { useCallback, useEffect, useRef, useState } from 'react'
3import { supabase } from '@/lib/supabase'
4import { useAuth } from '@/hooks/useAuth'
5
6type Post = {
7 id: string
8 user_id: string
9 body: string
10 image_url: string | null
11 like_count: number
12 comment_count: number
13 created_at: string
14 author: { username: string; display_name: string; avatar_url: string | null }
15 viewer_has_liked?: boolean
16}
17
18const PAGE_SIZE = 10
19
20export 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)
28
29 const fetchPage = useCallback(async (after?: string) => {
30 if (!user?.id || loading) return
31 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_at
44 setHasMore(rows.length === PAGE_SIZE)
45 setLoading(false)
46 }, [user?.id, loading])
47
48 useEffect(() => { fetchPage() }, [user?.id])
49
50 // Infinite scroll via Intersection Observer
51 useEffect(() => {
52 if (!bottomRef.current) return
53 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])
60
61 // Realtime new posts
62 useEffect(() => {
63 if (!user?.id) return
64 const channel = supabase
65 .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])
72
73 const loadNewPosts = async () => {
74 setNewPostCount(0)
75 await fetchPage()
76 }
77
78 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.

3

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.

prompt.txt
1Build a PostCard component at src/components/PostCard.tsx.
2
3Requirements:
4- Card layout with: Avatar + display_name + username + relative timestamp in the header
5- 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 card
10 - Share Button: copies the post URL to clipboard and shows a brief 'Copied!' Toast
11- CommentSection (collapsible):
12 - Show last 3 comments with avatar, username, body
13 - 'View all N comments' link if comment_count > 3
14 - Comment input at the bottom: Textarea + Submit Button
15 - On comment submit: INSERT into comments, update local comment_count optimistically
16- PostCard takes a single post prop of type Post plus an onLikeToggle callback
17- Use React.memo to prevent re-renders when unrelated posts in the list update

Pro 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.

4

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.

prompt.txt
1Build a ComposePost component at src/components/ComposePost.tsx.
2
3Requirements:
4- Card with the user's Avatar, a Textarea (placeholder: 'What's on your mind?'), and a footer row
5- 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 list
12 4. On error: show a Toast with the error message
13- Use react-hook-form + zod for validation: body min 1 char, max 280 chars
14- After insert: call supabase.from('profiles').update({ post_count: increment }) or handle this via a Postgres trigger

Pro 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.

5

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.

prompt.txt
1Build user profile pages at src/pages/Profile.tsx.
2
3Requirements:
4- Fetch profile by username from URL param: /profile/:username
5- Profile header:
6 - Large Avatar (96px)
7 - Display name and @username
8 - Bio text
9 - Stats row: '{post_count} Posts', '{follower_count} Followers', '{following_count} Following' each clickable opening a Sheet with the user list
10 - 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 Dialog
12- Posts grid below the header: a grid of post cards filtered to this profile's posts, ordered by created_at DESC
13- 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 background

Pro 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

src/hooks/useFeed.ts
1import { useCallback, useEffect, useRef, useState } from 'react'
2import { supabase } from '@/lib/supabase'
3import { useAuth } from '@/hooks/useAuth'
4
5export type Post = {
6 id: string; user_id: string; body: string; image_url: string | null
7 like_count: number; comment_count: number; created_at: string
8 viewer_has_liked: boolean
9 author: { username: string; display_name: string; avatar_url: string | null }
10}
11
12const PAGE_SIZE = 10
13
14export 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)
23
24 const fetchPage = useCallback(async (cursorTs?: string) => {
25 if (!user?.id || loadingRef.current) return
26 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_at
34 setHasMore(rows.length === PAGE_SIZE)
35 }
36 loadingRef.current = false; setLoading(false)
37 }, [user?.id])
38
39 useEffect(() => { fetchPage() }, [user?.id])
40
41 useEffect(() => {
42 if (!bottomRef.current) return
43 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.current
48 observer.observe(el)
49 return () => observer.unobserve(el)
50 }, [hasMore, fetchPage])
51
52 useEffect(() => {
53 if (!user?.id) return
54 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])
61
62 const loadNewPosts = useCallback(async () => { setNewPostCount(0); await fetchPage() }, [fetchPage])
63
64 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 }, [])
67
68 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.

ChatGPT Prompt

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().

Lovable Prompt

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).

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.