Feature spec
IntermediateCategory
communication-social
Build with AI
3-6 hours with Lovable or v0
Custom build
1-2 weeks custom dev
Running cost
$0/mo under 100 users, $25/mo at 1,000 users
Works on
Everything it takes to ship a Content Feed — parts, prompts, and real costs.
A content feed needs a Supabase posts table with a follow graph and a likes junction table, keyset cursor pagination (not offset — offset duplicates posts on active feeds), and a Supabase Realtime banner that shows 'X new posts' without forcing a scroll jump. With Lovable you can ship a working social feed with follows, likes, and images in 3-6 hours for $0/mo on the Supabase free tier, then $25/mo at 1,000 users.
What a Content Feed Actually Is
A content feed is the scrollable stream of posts at the heart of any social or community product — the timeline on Twitter, the home feed on Instagram, the activity stream in your SaaS app. Users post text and images, like and comment on others' posts, and choose which authors to follow so the feed becomes relevant to them. The architecture involves four pieces working together: a PostgreSQL schema for posts, likes, and follows; cursor-based pagination that keeps the feed stable as new posts arrive; optimistic UI updates so likes feel instant; and a Supabase Realtime banner that signals new content without hijacking the user's scroll position. Getting offset pagination wrong is the most common first-build failure — posts duplicate or disappear as new content is inserted.
What users consider table stakes in 2026
- Infinite scroll or paginated feed that loads quickly — the first 20 posts should appear in under 1 second
- Posts show author avatar, display name, timestamp, and content without requiring a separate profile lookup per post
- Like button with instant optimistic count update — tapping like must feel immediate, not wait for a server round trip
- Comment count visible on each post with tap-to-expand to see replies
- Follow and unfollow controls that filter the feed to show only authors the user cares about
- Pull-to-refresh on mobile and a 'New posts available' banner on web that does not force-scroll the user away from their current position
- Empty state with a clear call to action when the user follows nobody or has no posts yet
Anatomy of the Feature
Seven components make up a production content feed. AI tools generate the card UI and like button reliably — keyset pagination, the follow-graph query, and the N+1 author avatar problem are where first builds stall.
Feed list component
UIA virtualised list using react-virtuoso that renders each FeedItem as a shadcn/ui Card with author avatar, display name, relative timestamp, post body, optional image, and an action bar with like count and comment count. Virtualisation ensures smooth scrolling even with hundreds of posts in memory.
Note: Without virtualisation, rendering 100+ post cards in a plain div map causes visible frame drops on mid-range phones. react-virtuoso handles dynamic item heights automatically — react-window requires fixed heights.
Post composer
UIA Textarea with a character counter (max 280 or custom limit) and an image upload trigger. Submitting calls a Supabase INSERT on the posts table. The image file is uploaded to a Supabase Storage bucket first; the resulting public URL is stored in the image_url column of the post row.
Note: Set a client-side max file size check of 5 MB before uploading — Supabase Storage will reject larger files but the error message is not user-friendly.
Likes and reactions
DataA post_likes junction table with a composite PRIMARY KEY on (post_id, user_id) prevents duplicate likes. On like: optimistically increment the count in React state, then call a Supabase INSERT. On unlike: decrement optimistically and call a DELETE. If the server call fails, roll back the optimistic update.
Note: The unique constraint at the database level is the safety net — the optimistic UI guard prevents the visible flicker, but the DB constraint prevents data corruption if the client sends a duplicate request.
Pagination and cursor
BackendKeyset cursor pagination using WHERE created_at < :last_cursor ORDER BY created_at DESC LIMIT 20. The cursor is the created_at timestamp of the last post in the current page, stored in component state or the URL. On scroll-to-bottom, fetch the next page using the cursor and append to the existing list.
Note: Never use offset pagination (LIMIT 20 OFFSET 40) on a live feed. If 2 new posts are inserted between page 1 and page 2 fetches, OFFSET 40 skips 2 posts that should appear. Keyset pagination is immune to this because it anchors to a specific row's timestamp.
Follow graph
DataA user_follows table with PRIMARY KEY (follower_id, followee_id). The feed query JOINs user_follows to filter posts WHERE author_id IN (SELECT followee_id FROM user_follows WHERE follower_id = auth.uid()). Follows and unfollows are instant INSERTs and DELETEs on this table.
Note: Add a fallback for users who follow nobody: if the filtered feed returns 0 posts, switch to a global feed (all posts, no follow filter) so the empty-state experience makes sense before the user has built their follow graph.
Real-time new post indicator
BackendA Supabase Realtime postgres_changes subscription on the posts table. Instead of auto-inserting new posts into the virtualised list (which would scroll the user to the top), the subscription increments a newPostCount state variable. A fixed banner at the top says 'X new posts — tap to refresh' and reloads the feed from the top when tapped.
Note: Auto-inserting new posts at the top is a UX anti-pattern — it moves content the user is reading. The banner approach is the correct pattern used by Twitter, Facebook, and LinkedIn.
Image display
UIPost images from Supabase Storage are rendered using Next.js Image component (in v0) or standard img with loading='lazy' (in Lovable/Vite). Supabase Storage public URLs support image transformation parameters for resizing thumbnails.
Note: Always set explicit width and height on feed images or use aspect-ratio CSS to prevent layout shift as images load. CLS (Cumulative Layout Shift) on image-heavy feeds is the most common Lighthouse complaint.
The data model
Three tables form the feed schema: posts, likes junction, and follow graph. Run this in the Supabase SQL editor.
1create table public.posts (2 id uuid primary key default gen_random_uuid(),3 author_id uuid references auth.users(id) on delete cascade not null,4 body text not null check (char_length(body) <= 280),5 image_url text,6 deleted_at timestamptz,7 created_at timestamptz not null default now()8);910create table public.post_likes (11 post_id uuid references public.posts(id) on delete cascade not null,12 user_id uuid references auth.users(id) on delete cascade not null,13 created_at timestamptz not null default now(),14 primary key (post_id, user_id)15);1617create table public.user_follows (18 follower_id uuid references auth.users(id) on delete cascade not null,19 followee_id uuid references auth.users(id) on delete cascade not null,20 created_at timestamptz not null default now(),21 primary key (follower_id, followee_id),22 check (follower_id != followee_id)23);2425alter table public.posts enable row level security;26alter table public.post_likes enable row level security;27alter table public.user_follows enable row level security;2829-- Posts are publicly readable (adjust to follower-only if needed)30create policy "posts are public"31 on public.posts for select32 using (deleted_at is null);3334-- Authenticated users can create posts35create policy "authenticated can post"36 on public.posts for insert37 to authenticated38 with check (auth.uid() = author_id);3940-- Authors can soft-delete their own posts41create policy "author can soft delete"42 on public.posts for update43 using (auth.uid() = author_id);4445-- Authenticated users can like posts46create policy "authenticated can like"47 on public.post_likes for insert48 to authenticated49 with check (auth.uid() = user_id);5051create policy "authenticated can unlike"52 on public.post_likes for delete53 using (auth.uid() = user_id);5455create policy "likes are public"56 on public.post_likes for select57 using (true);5859-- Authenticated users can follow others60create policy "authenticated can follow"61 on public.user_follows for insert62 to authenticated63 with check (auth.uid() = follower_id);6465create policy "authenticated can unfollow"66 on public.user_follows for delete67 using (auth.uid() = follower_id);6869create policy "follows are public"70 on public.user_follows for select71 using (true);7273-- Indexes for feed performance74create index posts_author_created_idx75 on public.posts (author_id, created_at desc)76 where deleted_at is null;7778create index posts_created_idx79 on public.posts (created_at desc)80 where deleted_at is null;8182create index user_follows_follower_idx83 on public.user_follows (follower_id);8485create index post_likes_post_idx86 on public.post_likes (post_id);Heads up: The partial index on posts WHERE deleted_at IS NULL keeps the feed query fast by ignoring soft-deleted rows without a full table scan. The follow graph index on follower_id is essential for the filtered feed JOIN — without it, every feed load scans the entire user_follows table.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
Best all-round path: Lovable auto-wires Supabase Realtime and Auth, generates the virtualised feed list, like button with optimistic update, and follow graph in a single prompt.
Step by step
- 1Create a new Lovable project and connect Lovable Cloud so Supabase is provisioned with auth
- 2Paste the prompt below in Agent Mode — Lovable generates the feed page, post composer, like button, follow controls, and Realtime banner
- 3Verify the three tables and RLS policies were created in the Supabase dashboard
- 4Test the like button: click it and confirm the count updates instantly before the server response returns
- 5Create two user accounts and test the follow/unfollow flow — verify the feed shows only posts from followed users, and falls back to a global feed when following nobody
Build a social content feed using Supabase. Create three tables: posts (id uuid pk, author_id uuid references auth.users, body text max 280 chars, image_url text, deleted_at timestamptz, created_at timestamptz), post_likes (post_id, user_id, created_at, PRIMARY KEY (post_id, user_id)), user_follows (follower_id, followee_id, created_at, PRIMARY KEY (follower_id, followee_id), CHECK follower != followee). Enable RLS: posts SELECT is public (exclude deleted_at IS NOT NULL rows); authenticated INSERT posts where author_id = auth.uid(); authenticated INSERT post_likes; authenticated DELETE own likes; authenticated INSERT/DELETE user_follows. Feed page: virtualised list using react-virtuoso. Each FeedItem card: author avatar + display name + relative timestamp (use author data from a JOIN on profiles, not separate fetches to avoid N+1 queries). Post body text. Optional image with lazy loading and fixed aspect-ratio container to prevent layout shift. Like button: optimistic UI — increment count instantly on click, call Supabase INSERT post_likes, rollback on error. Comment count badge (placeholder for now). Follow/unfollow button per author. Use keyset cursor pagination: WHERE created_at < :cursor ORDER BY created_at DESC LIMIT 20 — NOT offset pagination. Feed is filtered to posts from followed users only; if user follows 0 people, show global feed with a 'Follow more people to personalise your feed' banner. Post composer at top: textarea with 280-char counter, image upload button (Supabase Storage, 5 MB max). Subscribe to Supabase Realtime postgres_changes on posts INSERT — show a 'X new posts — tap to refresh' banner at the top instead of auto-inserting to avoid scroll jump. Empty state when no posts exist. Soft-deleted posts shown as 'Post removed' placeholder.
Where this path bites
- AI may generate offset pagination instead of keyset cursor on first pass — verify the SQL query uses WHERE created_at < :cursor, not LIMIT x OFFSET y
- The follow-graph filtered feed query sometimes omits the fallback to global feed when following 0 users — check the empty following case manually
Third-party services you'll need
A content feed is largely self-contained on Supabase. Image hosting optionally benefits from a CDN transform service at scale.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | PostgreSQL for posts/likes/follows, Realtime for new-post banner, Storage for image uploads | Free: 500 MB DB, 1 GB Storage, 2 GB bandwidth | Pro $25/mo: 8 GB DB, 100 GB Storage |
| Cloudinary or imgix | Image resize and WebP conversion for feed thumbnails — reduces load time on image-heavy feeds | Cloudinary: 25 credits/mo free | Pay-as-you-go based on transformation volume (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier handles the post volume, Realtime connections, and image storage for a small community feed. Zero cost.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Duplicate posts appear when scrolling
Symptom: The feed uses offset pagination (LIMIT 20 OFFSET 40). Between the page 1 and page 2 fetch, 2 new posts are inserted at the top of the chronological feed. The OFFSET shifts by those 2 rows, so the last 2 posts from page 1 re-appear at the start of page 2 — the user sees the same post twice.
Fix: Switch to keyset cursor pagination: WHERE created_at < :last_seen_at ORDER BY created_at DESC LIMIT 20. Store the created_at of the last post in the current page as the cursor. On next page fetch, pass that timestamp as the WHERE filter. Keyset is immune to new-post insertion because it anchors to a specific timestamp, not a row count.
Like count flickers back after clicking
Symptom: The optimistic update adds 1 to the like count in local state. When the Supabase mutation resolves, React Query or SWR refetches the post data — but the cached server response was fetched before the like was inserted, so the count returns to the pre-like value briefly before stabilising. The user sees the heart icon flash unchecked.
Fix: Use React Query's onMutate/onError rollback pattern: in onMutate, cancel the pending refetch and set the optimistic data; in onError, roll back to the previous data; in onSettled, refetch. Or with SWR: use mutate(key, optimisticData, { rollbackOnError: true }).
Feed is empty for all users at launch
Symptom: The AI generates a feed query that strictly filters posts to followed users. At launch, no user follows anyone — the follow graph is empty. Every user opens the feed and sees the empty state. If the empty state just says 'No posts yet' with no explanation, users think the app is broken.
Fix: Add a fallback in the feed query: if SELECT COUNT(*) FROM user_follows WHERE follower_id = auth.uid() returns 0, run the global feed query instead (no follow filter). Show a 'Discover people to follow' call-to-action above the global feed to guide the user toward building their follow graph.
N+1 queries on author avatars
Symptom: The AI fetches the 20 posts for the current page, then renders each FeedItem which triggers an individual Supabase SELECT for the author's profile to get their avatar and display name. The result is 21 database queries for a single page load — 1 for posts, 20 for profiles.
Fix: JOIN the profiles table in the posts query: SELECT posts.*, profiles.display_name, profiles.avatar_url FROM posts LEFT JOIN profiles ON posts.author_id = profiles.id WHERE ... This collapses 21 queries into 1.
Best practices
Use keyset cursor pagination from day one — switching from offset to keyset later requires refactoring pagination logic across the entire feed component
Always JOIN author profiles in the feed query — a separate fetch per post (N+1) is the most common feed performance mistake
Show a 'new posts available' banner instead of auto-inserting posts into the list — auto-insert disrupts reading and is the pattern users complain about most on social feeds
Implement optimistic like updates with explicit rollback on error — a like that flickers is more trust-destroying than a like that takes 200 ms
Add the follow-graph empty-state fallback before launch — a feed that shows nothing for new users kills activation
Set explicit width, height, or aspect-ratio on all feed images — layout shift as images load is the fastest way to tank your Lighthouse CLS score
Index posts on (created_at desc) with a partial index WHERE deleted_at IS NULL — the feed query runs on every page load and needs to be fast
When You Need Custom Development
Lovable and v0 handle chronological feeds with follows and likes at small-to-medium scale. Four scenarios push past what they can build reliably:
- You need algorithmic feed ranking based on engagement signals, user interest graphs, or ML scoring — chronological feeds with engagement weighting require background scoring jobs
- You need fan-out on write for more than 10 million users where a single popular post must push to millions of personal feeds instantly without a slow JOIN on every read
- You need video posts with adaptive bitrate streaming (HLS/DASH) — image posts on Supabase Storage are fine; video at scale requires Cloudflare Stream or similar
- You need a content moderation pipeline with AI classification and human review before posts go live — Supabase INSERT triggers can call an API, but the full review queue UI is a custom build
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
How do I build a social feed like Instagram or Twitter?
Start with Lovable or v0 using the prompt from this page. You get posts, likes, follows, and real-time new-post notifications on Supabase in 3-6 hours. The architecture — a posts table, a likes junction, and a follow graph — is exactly what Instagram and Twitter use at their core; only the scale and ranking algorithm differ.
Can I add a content feed to a Lovable app?
Yes — it is one of the strongest Lovable use cases. Supabase Realtime auto-wires with Lovable, and the posts table with likes and follows scaffolds well in a single prompt. Specify keyset pagination explicitly in the prompt; Lovable sometimes defaults to offset pagination, which causes duplicate posts on active feeds.
What is the difference between a chronological and algorithmic feed?
A chronological feed shows posts in the order they were created — newest first. An algorithmic feed reorders posts based on predicted engagement: posts you are likely to interact with appear first even if they are older. Chronological is simpler to build (ORDER BY created_at DESC) and more transparent to users. Algorithmic ranking requires ML scoring and is only worth building after you have consistent user data.
How do I implement infinite scroll in a feed?
Use react-virtuoso with a virtualised list and an onRangeChanged callback that detects when the user scrolls near the bottom. When the last visible item is within 3 items of the end of the list, fetch the next page using the keyset cursor (WHERE created_at < :lastTimestamp) and append the results to the existing list. React Query or SWR makes the cursor fetch clean.
How do I show only posts from people the user follows?
Add a user_follows table with a composite primary key on (follower_id, followee_id). In the feed query, filter posts with WHERE author_id IN (SELECT followee_id FROM user_follows WHERE follower_id = auth.uid()). Always add a fallback: if the user follows nobody, return the global feed instead of an empty page.
What happens if two users like the same post at the same time?
The post_likes table has a composite PRIMARY KEY on (post_id, user_id), which is also a unique constraint. If two different users like the same post simultaneously, both INSERTs succeed because they have different user_ids. If the same user double-clicks, the second INSERT hits the unique constraint and fails with a conflict — handle this with INSERT ... ON CONFLICT DO NOTHING on the client.
How do I add images to posts in a feed?
Upload the image to a Supabase Storage bucket from the post composer. Get the public URL, store it in the post's image_url column alongside the text body. Render it in the FeedItem card with a fixed aspect-ratio container to prevent layout shift. Set a 5 MB client-side file size limit before uploading — Supabase rejects larger files with an error that is not user-friendly by default.
What is keyset pagination and why does it matter for feeds?
Keyset pagination uses WHERE created_at < :lastTimestamp to fetch the next page, anchored to a specific row's timestamp. Offset pagination uses LIMIT 20 OFFSET 40. On a live feed where new posts arrive constantly, offset pagination is broken: if 3 posts are inserted between page 1 and page 2 fetches, OFFSET shifts by 3 rows and you miss 3 posts or see 3 posts twice. Keyset is immune to this because the timestamp anchor does not move when new rows are inserted above it.
Need this feature production-ready?
RapidDev builds a content feed into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.