# How to Build a Social Media Feed with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

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

```
Create a social media feed schema in Supabase. Tables:

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

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

3. likes: id, post_id (references posts), user_id (references auth.users), created_at, UNIQUE(post_id, user_id)

4. comments: id, post_id (references posts), user_id (references auth.users), parent_id (references comments, nullable), body (text), created_at

5. follows: id, follower_id (references auth.users), followee_id (references auth.users), created_at, UNIQUE(follower_id, followee_id), CHECK(follower_id != followee_id)

RLS:
- profiles: public SELECT, own UPDATE
- posts: public SELECT, own INSERT/UPDATE/DELETE
- likes: public SELECT, authenticated INSERT/DELETE own rows
- comments: public SELECT, authenticated INSERT, own DELETE
- follows: public SELECT, own INSERT/DELETE

Indexes:
- posts(user_id, created_at DESC)
- follows(follower_id, followee_id)
- likes(post_id, user_id)
- comments(post_id, parent_id, created_at)

Triggers:
- After INSERT on likes: UPDATE posts SET like_count = like_count + 1
- After DELETE on likes: UPDATE posts SET like_count = like_count - 1
- After INSERT on comments: UPDATE posts SET comment_count = comment_count + 1
- 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
- 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
import { useCallback, useEffect, useRef, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

type Post = {
  id: string
  user_id: string
  body: string
  image_url: string | null
  like_count: number
  comment_count: number
  created_at: string
  author: { username: string; display_name: string; avatar_url: string | null }
  viewer_has_liked?: boolean
}

const PAGE_SIZE = 10

export function useFeed() {
  const { user } = useAuth()
  const [posts, setPosts] = useState<Post[]>([])
  const [loading, setLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)
  const [newPostCount, setNewPostCount] = useState(0)
  const cursor = useRef<string | undefined>()
  const bottomRef = useRef<HTMLDivElement>(null)

  const fetchPage = useCallback(async (after?: string) => {
    if (!user?.id || loading) return
    setLoading(true)
    const { data } = await supabase.rpc('get_feed', {
      p_user_id: user.id,
      p_cursor: after ?? new Date().toISOString(),
      p_limit: PAGE_SIZE,
    })
    const rows = (data ?? []) as Post[]
    if (after) {
      setPosts((prev) => [...prev, ...rows])
    } else {
      setPosts(rows)
    }
    cursor.current = rows[rows.length - 1]?.created_at
    setHasMore(rows.length === PAGE_SIZE)
    setLoading(false)
  }, [user?.id, loading])

  useEffect(() => { fetchPage() }, [user?.id])

  // Infinite scroll via Intersection Observer
  useEffect(() => {
    if (!bottomRef.current) return
    const observer = new IntersectionObserver(
      (entries) => { if (entries[0].isIntersecting && hasMore && !loading) fetchPage(cursor.current) },
      { threshold: 0.1 }
    )
    observer.observe(bottomRef.current)
    return () => observer.disconnect()
  }, [hasMore, loading, fetchPage])

  // Realtime new posts
  useEffect(() => {
    if (!user?.id) return
    const channel = supabase
      .channel(`feed:${user.id}`)
      .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, () => {
        setNewPostCount((n) => n + 1)
      })
      .subscribe()
    return () => { supabase.removeChannel(channel) }
  }, [user?.id])

  const loadNewPosts = async () => {
    setNewPostCount(0)
    await fetchPage()
  }

  return { posts, loading, hasMore, newPostCount, loadNewPosts, bottomRef }
}
```

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

```
Build a PostCard component at src/components/PostCard.tsx.

Requirements:
- Card layout with: Avatar + display_name + username + relative timestamp in the header
- Post body text (break-words, whitespace-pre-wrap to respect line breaks)
- If image_url exists: a full-width rounded image below the body (max-h-96 object-cover)
- Action row at the bottom:
  - 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.
  - Comment Button: shows comment_count, clicking expands a CommentSection below the card
  - Share Button: copies the post URL to clipboard and shows a brief 'Copied!' Toast
- CommentSection (collapsible):
  - Show last 3 comments with avatar, username, body
  - 'View all N comments' link if comment_count > 3
  - Comment input at the bottom: Textarea + Submit Button
  - On comment submit: INSERT into comments, update local comment_count optimistically
- PostCard takes a single post prop of type Post plus an onLikeToggle callback
- 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.

```
Build a ComposePost component at src/components/ComposePost.tsx.

Requirements:
- Card with the user's Avatar, a Textarea (placeholder: 'What's on your mind?'), and a footer row
- Character counter: shows '280' counting down. Text turns amber at 50 remaining, red at 20 remaining. Disable submit when 0.
- Image button: file input (accept='image/*') hidden, triggered by a camera icon Button. Shows thumbnail preview with an 'x' remove button.
- Submit Button: disabled when body is empty or uploading. Shows a spinner during submission.
- On submit:
  1. If image selected: upload to Supabase Storage at path: posts/{userId}/{Date.now()}.{ext}. Get public URL.
  2. INSERT into posts: { user_id, body, image_url }
  3. On success: clear the form, optimistically prepend the new post to the feed list
  4. On error: show a Toast with the error message
- Use react-hook-form + zod for validation: body min 1 char, max 280 chars
- 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.

```
Build user profile pages at src/pages/Profile.tsx.

Requirements:
- Fetch profile by username from URL param: /profile/:username
- Profile header:
  - Large Avatar (96px)
  - Display name and @username
  - Bio text
  - Stats row: '{post_count} Posts', '{follower_count} Followers', '{following_count} Following' — each clickable opening a Sheet with the user list
  - Follow/Unfollow Button: show 'Follow' if not following, 'Following' (with hover 'Unfollow') if following. On click: INSERT or DELETE from follows.
  - If viewing own profile: replace Follow button with 'Edit Profile' Button opening a Dialog
- Posts grid below the header: a grid of post cards filtered to this profile's posts, ordered by created_at DESC
- Edit Profile Dialog: form for display_name, bio, and avatar upload. On save: UPDATE profiles.
- useFollowStatus hook: checks if auth.uid() follows this profile_id via supabase.from('follows').select().eq('follower_id', userId).eq('followee_id', profileId).maybeSingle()
- 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 example

File: `src/hooks/useFeed.ts`

```typescript
import { useCallback, useEffect, useRef, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

export type Post = {
  id: string; user_id: string; body: string; image_url: string | null
  like_count: number; comment_count: number; created_at: string
  viewer_has_liked: boolean
  author: { username: string; display_name: string; avatar_url: string | null }
}

const PAGE_SIZE = 10

export function useFeed() {
  const { user } = useAuth()
  const [posts, setPosts] = useState<Post[]>([])
  const [loading, setLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)
  const [newPostCount, setNewPostCount] = useState(0)
  const cursorRef = useRef<string | undefined>()
  const loadingRef = useRef(false)
  const bottomRef = useRef<HTMLDivElement>(null)

  const fetchPage = useCallback(async (cursorTs?: string) => {
    if (!user?.id || loadingRef.current) return
    loadingRef.current = true; setLoading(true)
    const { data, error } = await supabase.rpc('get_feed', {
      p_user_id: user.id, p_cursor: cursorTs ?? new Date().toISOString(), p_limit: PAGE_SIZE,
    })
    if (!error) {
      const rows = (data ?? []) as Post[]
      cursorTs ? setPosts(prev => [...prev, ...rows]) : setPosts(rows)
      if (rows.length > 0) cursorRef.current = rows[rows.length - 1].created_at
      setHasMore(rows.length === PAGE_SIZE)
    }
    loadingRef.current = false; setLoading(false)
  }, [user?.id])

  useEffect(() => { fetchPage() }, [user?.id])

  useEffect(() => {
    if (!bottomRef.current) return
    const observer = new IntersectionObserver(
      entries => { if (entries[0].isIntersecting && hasMore && !loadingRef.current) fetchPage(cursorRef.current) },
      { threshold: 0.1 }
    )
    const el = bottomRef.current
    observer.observe(el)
    return () => observer.unobserve(el)
  }, [hasMore, fetchPage])

  useEffect(() => {
    if (!user?.id) return
    const channel = supabase.channel(`feed-new-posts:${user.id}`)
      .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, payload => {
        if ((payload.new as Post).user_id !== user.id) setNewPostCount(n => n + 1)
      })
      .subscribe()
    return () => { supabase.removeChannel(channel) }
  }, [user?.id])

  const loadNewPosts = useCallback(async () => { setNewPostCount(0); await fetchPage() }, [fetchPage])

  const updatePostLike = useCallback((postId: string, liked: boolean, delta: number) => {
    setPosts(prev => prev.map(p => p.id === postId ? { ...p, viewer_has_liked: liked, like_count: p.like_count + delta } : p))
  }, [])

  return { posts, loading, hasMore, newPostCount, loadNewPosts, bottomRef, updatePostLike }
}
```

## Common mistakes

- **Using offset-based pagination (OFFSET, LIMIT) for the feed** — When new posts arrive at the top of the feed, they shift all offsets. A user who loaded page 2 (posts 11-20) will see duplicates or miss posts when they load page 3 (posts 21-30) because the offset has shifted. Fix: 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.
- **Computing like_count via COUNT(*) in the feed query** — Counting likes for every post in a feed query (SELECT COUNT(*) FROM likes WHERE post_id = p.id) creates N+1 subqueries. With 10 posts per page, that is 10 extra COUNT queries on every page load. Fix: 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.
- **Auto-scrolling to the top when Realtime delivers new posts** — Automatically prepending new posts and scrolling up disrupts the reading experience. If the user is reading post 15, they do not want to be teleported back to post 1 because someone they follow published. Fix: 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.
- **Missing the UNIQUE constraint on follows(follower_id, followee_id)** — Without the unique constraint, clicking Follow multiple times creates multiple rows in the follows table. The follower_count trigger fires once per row, inflating counts incorrectly. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/social-media-feed
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/social-media-feed
