Build a social media feed with V0 featuring posts, likes, comments, infinite scroll, and real-time updates using Next.js, Supabase, and Supabase Realtime. You'll create an optimistic like system, cursor-based pagination, user profiles, and live post streaming — all in about 1-2 hours.
What you're building
Social feeds are the backbone of community platforms — whether it's a niche interest group, a company internal feed, or a neighborhood forum. Users expect to post updates, like and comment, see new content appear instantly, and scroll endlessly without page loads.
V0 generates the feed components, post cards, and interaction logic from prompts. Supabase handles the database, auth, file storage for images, and Realtime subscriptions for live updates. The entire stack runs on Vercel serverless with no infrastructure to manage.
The architecture uses cursor-based pagination via an API route for efficient infinite scroll, Server Actions for mutations (create post, toggle like, add comment), Supabase Realtime for live post streaming in a client component, and Server Components for user profile pages.
Final result
A fully interactive social media feed with post creation, likes, comments, infinite scroll, user profiles, and real-time updates powered by Supabase Realtime.
Tech stack
Prerequisites
- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Supabase Auth configured for user registration and login
- Basic understanding of social media feed interactions (posts, likes, comments)
Build steps
Set up the social feed database schema
Open V0 and create a new project. Use the Connect panel to add Supabase. Create the profiles, posts, likes, and comments tables with proper indexes and foreign key relationships.
1// Paste this prompt into V0's AI chat:2// Create a Supabase schema for a social media feed:3// 1. profiles table: id (uuid PK references auth.users), username (text UNIQUE), display_name (text), avatar_url (text), bio (text)4// 2. posts table: id (uuid PK), author_id (uuid FK to profiles), content (text), image_url (text nullable), likes_count (int DEFAULT 0), comments_count (int DEFAULT 0), created_at (timestamptz)5// 3. likes table: id (uuid PK), post_id (uuid FK), user_id (uuid FK), UNIQUE(post_id, user_id)6// 4. comments table: id (uuid PK), post_id (uuid FK), author_id (uuid FK), content (text), created_at (timestamptz)7// Add index on posts.created_at for cursor-based pagination.8// RLS: authenticated users can create posts/likes/comments, public can read.9// Enable Realtime replication on the posts table.10// Seed 20 sample posts with varied timestamps.Pro tip: Enable Supabase Realtime on the posts table in your Supabase Dashboard under Database > Replication. This is required for live post streaming in the feed.
Expected result: Four tables created with indexes, RLS policies, Realtime enabled on posts, and 20 sample posts seeded for testing the feed.
Build the post feed with cursor-based pagination
Create the main feed page with an API route that supports cursor-based pagination. The feed loads 20 posts at a time, with each response including a cursor for the next page. This is more efficient than offset pagination for social feeds.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function GET(req: NextRequest) {10 const { searchParams } = new URL(req.url)11 const cursor = searchParams.get('cursor')12 const limit = 201314 let query = supabase15 .from('posts')16 .select('*, profiles(username, display_name, avatar_url)')17 .order('created_at', { ascending: false })18 .limit(limit + 1)1920 if (cursor) {21 query = query.lt('created_at', cursor)22 }2324 const { data: posts, error } = await query2526 if (error) {27 return NextResponse.json({ error: error.message }, { status: 500 })28 }2930 const hasMore = (posts?.length ?? 0) > limit31 const items = hasMore ? posts!.slice(0, limit) : posts!32 const nextCursor = hasMore ? items[items.length - 1].created_at : null3334 return NextResponse.json({ posts: items, nextCursor })35}Expected result: GET /api/feed returns 20 posts with author profiles. GET /api/feed?cursor=2025-01-01T00:00:00Z returns the next 20 posts older than the cursor.
Create the feed UI with infinite scroll and Realtime
Build a client component that renders the post feed, handles infinite scroll loading, and subscribes to Supabase Realtime for new posts appearing at the top without refresh.
1'use client'23import { useEffect, useState, useCallback, useOptimistic } from 'react'4import { createClient } from '@/lib/supabase/client'5import { Card, CardContent } from '@/components/ui/card'6import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'7import { Button } from '@/components/ui/button'8import { Skeleton } from '@/components/ui/skeleton'9import { Separator } from '@/components/ui/separator'10import { Heart } from 'lucide-react'11import { toggleLike } from '@/app/actions/feed'1213type Post = {14 id: string15 content: string16 image_url: string | null17 likes_count: number18 comments_count: number19 created_at: string20 profiles: { username: string; display_name: string; avatar_url: string }21}2223export function FeedList({ initialPosts }: { initialPosts: Post[] }) {24 const [posts, setPosts] = useState<Post[]>(initialPosts)25 const [cursor, setCursor] = useState<string | null>(26 initialPosts.length > 0 ? initialPosts[initialPosts.length - 1].created_at : null27 )28 const [loading, setLoading] = useState(false)29 const supabase = createClient()3031 useEffect(() => {32 const channel = supabase33 .channel('feed')34 .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, (payload) => {35 setPosts((prev) => [payload.new as Post, ...prev])36 })37 .subscribe()38 return () => { supabase.removeChannel(channel) }39 }, [supabase])4041 const loadMore = useCallback(async () => {42 if (!cursor || loading) return43 setLoading(true)44 const res = await fetch(`/api/feed?cursor=${cursor}`)45 const data = await res.json()46 setPosts((prev) => [...prev, ...data.posts])47 setCursor(data.nextCursor)48 setLoading(false)49 }, [cursor, loading])5051 return (52 <div className="max-w-2xl mx-auto space-y-4">53 {posts.map((post) => (54 <Card key={post.id}>55 <CardContent className="p-4">56 <div className="flex items-center gap-3 mb-3">57 <Avatar>58 <AvatarImage src={post.profiles?.avatar_url} />59 <AvatarFallback>{post.profiles?.display_name?.[0]}</AvatarFallback>60 </Avatar>61 <div>62 <p className="font-semibold">{post.profiles?.display_name}</p>63 <p className="text-sm text-muted-foreground">64 {new Date(post.created_at).toLocaleDateString()}65 </p>66 </div>67 </div>68 <p className="mb-3">{post.content}</p>69 {post.image_url && (70 <img src={post.image_url} alt="" className="rounded-lg mb-3 w-full" />71 )}72 <Separator className="my-3" />73 <div className="flex gap-4">74 <form action={toggleLike}>75 <input type="hidden" name="postId" value={post.id} />76 <Button variant="ghost" size="sm" type="submit">77 <Heart className="w-4 h-4 mr-1" /> {post.likes_count}78 </Button>79 </form>80 <Button variant="ghost" size="sm">81 {post.comments_count} comments82 </Button>83 </div>84 </CardContent>85 </Card>86 ))}87 {loading && <Skeleton className="h-40 w-full" />}88 {cursor && (89 <Button variant="outline" className="w-full" onClick={loadMore} disabled={loading}>90 Load more91 </Button>92 )}93 </div>94 )95}Expected result: A social media feed with post Cards showing author Avatar, content, image, like/comment buttons. New posts appear at the top in real-time. Clicking 'Load more' fetches older posts.
Create Server Actions for post creation and like toggling
Build Server Actions for creating posts and toggling likes. The like toggle uses an upsert/delete pattern with atomic counter updates to prevent race conditions.
1'use server'23import { createClient } from '@/lib/supabase/server'4import { revalidatePath } from 'next/cache'56export async function createPost(formData: FormData) {7 const supabase = await createClient()8 const { data: { user } } = await supabase.auth.getUser()9 if (!user) return { error: 'Not authenticated' }1011 const content = formData.get('content') as string12 const imageUrl = formData.get('imageUrl') as string | null1314 const { error } = await supabase.from('posts').insert({15 author_id: user.id,16 content,17 image_url: imageUrl || null,18 })1920 if (error) return { error: error.message }21 revalidatePath('/feed')22 return { success: true }23}2425export async function toggleLike(formData: FormData) {26 const supabase = await createClient()27 const { data: { user } } = await supabase.auth.getUser()28 if (!user) return { error: 'Not authenticated' }2930 const postId = formData.get('postId') as string3132 const { data: existing } = await supabase33 .from('likes')34 .select('id')35 .eq('post_id', postId)36 .eq('user_id', user.id)37 .maybeSingle()3839 if (existing) {40 await supabase.from('likes').delete().eq('id', existing.id)41 await supabase.rpc('decrement_likes', { p_post_id: postId })42 } else {43 await supabase.from('likes').insert({ post_id: postId, user_id: user.id })44 await supabase.rpc('increment_likes', { p_post_id: postId })45 }4647 revalidatePath('/feed')48}4950export async function addComment(formData: FormData) {51 const supabase = await createClient()52 const { data: { user } } = await supabase.auth.getUser()53 if (!user) return { error: 'Not authenticated' }5455 const postId = formData.get('postId') as string56 const content = formData.get('content') as string5758 await supabase.from('comments').insert({59 post_id: postId,60 author_id: user.id,61 content,62 })6364 await supabase.rpc('increment_comments', { p_post_id: postId })65 revalidatePath('/feed')66}Pro tip: Create Supabase RPC functions for increment_likes, decrement_likes, and increment_comments that atomically update the counter columns. This prevents race conditions when multiple users like the same post simultaneously.
Expected result: Like toggling instantly checks/unchecks in the UI and updates the counter. Comments are added and the count increments. Both actions require authentication.
Build the post composer and user profile pages
Create a Textarea-based post composer and user profile pages that show a user's posts and follower counts. The composer supports text and optional image uploads.
1// Paste this prompt into V0's AI chat:2// Build two components for a social media feed:3// 1. A post composer component at the top of the feed with:4// - shadcn/ui Textarea for post content5// - Avatar of the current user next to the textarea6// - Image upload button (optional)7// - Post Button that calls createPost Server Action8// - Character count indicator9// 2. A user profile page at app/profile/[username]/page.tsx with:10// - Server Component that fetches the user's profile and posts11// - Avatar, display_name, username, and bio at the top12// - Post count, likes received total13// - Their posts displayed in Card components identical to the feed14// - Use Tabs to switch between Posts and Liked posts15// Use Supabase for data and shadcn/ui for all components.Expected result: A post composer with Textarea and Avatar at the top of the feed, and a user profile page showing the user's information and post history with Tab navigation.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function GET(req: NextRequest) {10 const { searchParams } = new URL(req.url)11 const cursor = searchParams.get('cursor')12 const limit = 201314 let query = supabase15 .from('posts')16 .select(17 '*, profiles(username, display_name, avatar_url)'18 )19 .order('created_at', { ascending: false })20 .limit(limit + 1)2122 if (cursor) {23 query = query.lt('created_at', cursor)24 }2526 const { data: posts, error } = await query2728 if (error) {29 return NextResponse.json(30 { error: error.message },31 { status: 500 }32 )33 }3435 const hasMore = (posts?.length ?? 0) > limit36 const items = hasMore ? posts!.slice(0, limit) : posts!37 const nextCursor = hasMore38 ? items[items.length - 1].created_at39 : null4041 return NextResponse.json({42 posts: items,43 nextCursor,44 })45}Customization ideas
Add image galleries in posts
Allow multiple images per post using Supabase Storage. Display them in a carousel using a shadcn/ui-compatible image slider component.
Add hashtag support
Parse hashtags from post content, store them in a tags table, and create clickable hashtag links that filter the feed to show only posts with that tag.
Add follow system
Create a follows table (follower_id, following_id) and filter the feed to show posts only from followed users, with a 'Discover' tab for all public posts.
Add post bookmarks
Let users save posts to a bookmarks collection with a bookmark icon. Create a saved posts page at app/saved/page.tsx showing bookmarked content.
Common pitfalls
Pitfall: Using offset-based pagination (OFFSET 20, 40, 60...) for the feed
How to avoid: Use cursor-based pagination with WHERE created_at < $cursor ORDER BY created_at DESC LIMIT 20. The cursor is the timestamp of the last post on the current page.
Pitfall: Updating like counts with separate SELECT + UPDATE instead of atomic RPC
How to avoid: Create Supabase RPC functions that use SET likes_count = likes_count + 1 (atomic increment) instead of reading and writing separately.
Pitfall: Not enabling Supabase Realtime on the posts table
How to avoid: Go to Supabase Dashboard > Database > Replication and add the posts table. The client subscription will then receive INSERT events.
Best practices
- Use cursor-based pagination for infinite scroll — it performs consistently regardless of feed depth and handles new posts gracefully
- Create atomic Supabase RPC functions for like/comment count updates to prevent race conditions on popular posts
- Use Supabase Realtime subscriptions to prepend new posts to the feed without polling, giving a real-time experience
- Use V0's Connect panel to set up Supabase with one click, then enable Realtime on the posts table in Supabase Dashboard
- Use Design Mode (Option+D) to visually adjust post Card padding, Avatar sizing, and feed spacing at zero credit cost
- Use Server Components for profile pages since they don't need real-time updates — better SEO and faster initial load
- Add Skeleton loading placeholders during infinite scroll to prevent layout shift when new posts load
AI prompts to try
Copy these prompts to build this project faster.
I'm building a social media feed with Next.js App Router and Supabase. I need: 1) Cursor-based pagination API for infinite scroll, 2) Optimistic like toggling with atomic counter updates, 3) Supabase Realtime subscription for new posts, 4) User profiles with post history. Help me design the schema and avoid race conditions on like counts.
Implement optimistic like toggling for a social media feed post. When the user clicks the heart icon: 1) Immediately toggle the heart icon fill and increment/decrement the displayed count in local state, 2) Fire a Server Action in the background that checks if a like exists (SELECT from likes), deletes it if found or inserts if not, 3) Call an RPC function to atomically update the likes_count, 4) If the Server Action fails, roll back the UI to the previous state. Use React 19 useOptimistic hook.
Frequently asked questions
How does real-time work in this feed?
Supabase Realtime uses WebSocket connections to push database changes to connected clients. When someone creates a new post, the INSERT event is sent to all clients subscribed to the posts table. The client component prepends the new post to the feed without any page refresh.
Will this scale to thousands of users?
Yes. Cursor-based pagination keeps queries fast at any depth. Atomic RPC functions prevent like count race conditions. Supabase Realtime handles thousands of concurrent WebSocket connections. For very high scale, consider Supabase Pro for connection pooling.
What V0 plan do I need?
V0 Free tier works. The feed uses standard Server Components, Server Actions, and shadcn/ui components. Supabase Realtime is included on Supabase free tier. Design Mode polish is also free.
Can I add image uploads to posts?
Yes. Use Supabase Storage to create a public bucket for post images. Add a file Input to the composer, upload the image via Supabase Storage client, get the public URL, and include it in the post creation Server Action.
How do I deploy the feed to production?
Click Share in V0, then Publish to Production. The Supabase connection is automatically configured from the Connect panel. Make sure Realtime is enabled on the posts table in Supabase Dashboard before deploying.
Can RapidDev help build a custom social platform?
Yes. RapidDev has built 600+ apps including social platforms with feeds, messaging, notifications, and content moderation. Book a free consultation to discuss your community platform requirements.
Can I add a follow system to filter the feed?
Yes. Create a follows table with follower_id and following_id columns. Modify the feed API query to join through follows where follower_id matches the current user. Add a Discover tab that shows all public posts regardless of follow status.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation