# How to Build Social media feed with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

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.

## Before you start

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

## Step-by-step guide

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

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for a social media feed:
// 1. profiles table: id (uuid PK references auth.users), username (text UNIQUE), display_name (text), avatar_url (text), bio (text)
// 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)
// 3. likes table: id (uuid PK), post_id (uuid FK), user_id (uuid FK), UNIQUE(post_id, user_id)
// 4. comments table: id (uuid PK), post_id (uuid FK), author_id (uuid FK), content (text), created_at (timestamptz)
// Add index on posts.created_at for cursor-based pagination.
// RLS: authenticated users can create posts/likes/comments, public can read.
// Enable Realtime replication on the posts table.
// 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.

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

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const cursor = searchParams.get('cursor')
  const limit = 20

  let query = supabase
    .from('posts')
    .select('*, profiles(username, display_name, avatar_url)')
    .order('created_at', { ascending: false })
    .limit(limit + 1)

  if (cursor) {
    query = query.lt('created_at', cursor)
  }

  const { data: posts, error } = await query

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  const hasMore = (posts?.length ?? 0) > limit
  const items = hasMore ? posts!.slice(0, limit) : posts!
  const nextCursor = hasMore ? items[items.length - 1].created_at : null

  return NextResponse.json({ posts: items, nextCursor })
}
```

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

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

```
'use client'

import { useEffect, useState, useCallback, useOptimistic } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Card, CardContent } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import { Heart } from 'lucide-react'
import { toggleLike } from '@/app/actions/feed'

type Post = {
  id: string
  content: string
  image_url: string | null
  likes_count: number
  comments_count: number
  created_at: string
  profiles: { username: string; display_name: string; avatar_url: string }
}

export function FeedList({ initialPosts }: { initialPosts: Post[] }) {
  const [posts, setPosts] = useState<Post[]>(initialPosts)
  const [cursor, setCursor] = useState<string | null>(
    initialPosts.length > 0 ? initialPosts[initialPosts.length - 1].created_at : null
  )
  const [loading, setLoading] = useState(false)
  const supabase = createClient()

  useEffect(() => {
    const channel = supabase
      .channel('feed')
      .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, (payload) => {
        setPosts((prev) => [payload.new as Post, ...prev])
      })
      .subscribe()
    return () => { supabase.removeChannel(channel) }
  }, [supabase])

  const loadMore = useCallback(async () => {
    if (!cursor || loading) return
    setLoading(true)
    const res = await fetch(`/api/feed?cursor=${cursor}`)
    const data = await res.json()
    setPosts((prev) => [...prev, ...data.posts])
    setCursor(data.nextCursor)
    setLoading(false)
  }, [cursor, loading])

  return (
    <div className="max-w-2xl mx-auto space-y-4">
      {posts.map((post) => (
        <Card key={post.id}>
          <CardContent className="p-4">
            <div className="flex items-center gap-3 mb-3">
              <Avatar>
                <AvatarImage src={post.profiles?.avatar_url} />
                <AvatarFallback>{post.profiles?.display_name?.[0]}</AvatarFallback>
              </Avatar>
              <div>
                <p className="font-semibold">{post.profiles?.display_name}</p>
                <p className="text-sm text-muted-foreground">
                  {new Date(post.created_at).toLocaleDateString()}
                </p>
              </div>
            </div>
            <p className="mb-3">{post.content}</p>
            {post.image_url && (
              <img src={post.image_url} alt="" className="rounded-lg mb-3 w-full" />
            )}
            <Separator className="my-3" />
            <div className="flex gap-4">
              <form action={toggleLike}>
                <input type="hidden" name="postId" value={post.id} />
                <Button variant="ghost" size="sm" type="submit">
                  <Heart className="w-4 h-4 mr-1" /> {post.likes_count}
                </Button>
              </form>
              <Button variant="ghost" size="sm">
                {post.comments_count} comments
              </Button>
            </div>
          </CardContent>
        </Card>
      ))}
      {loading && <Skeleton className="h-40 w-full" />}
      {cursor && (
        <Button variant="outline" className="w-full" onClick={loadMore} disabled={loading}>
          Load more
        </Button>
      )}
    </div>
  )
}
```

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

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

```
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return { error: 'Not authenticated' }

  const content = formData.get('content') as string
  const imageUrl = formData.get('imageUrl') as string | null

  const { error } = await supabase.from('posts').insert({
    author_id: user.id,
    content,
    image_url: imageUrl || null,
  })

  if (error) return { error: error.message }
  revalidatePath('/feed')
  return { success: true }
}

export async function toggleLike(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return { error: 'Not authenticated' }

  const postId = formData.get('postId') as string

  const { data: existing } = await supabase
    .from('likes')
    .select('id')
    .eq('post_id', postId)
    .eq('user_id', user.id)
    .maybeSingle()

  if (existing) {
    await supabase.from('likes').delete().eq('id', existing.id)
    await supabase.rpc('decrement_likes', { p_post_id: postId })
  } else {
    await supabase.from('likes').insert({ post_id: postId, user_id: user.id })
    await supabase.rpc('increment_likes', { p_post_id: postId })
  }

  revalidatePath('/feed')
}

export async function addComment(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return { error: 'Not authenticated' }

  const postId = formData.get('postId') as string
  const content = formData.get('content') as string

  await supabase.from('comments').insert({
    post_id: postId,
    author_id: user.id,
    content,
  })

  await supabase.rpc('increment_comments', { p_post_id: postId })
  revalidatePath('/feed')
}
```

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

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

```
// Paste this prompt into V0's AI chat:
// Build two components for a social media feed:
// 1. A post composer component at the top of the feed with:
//    - shadcn/ui Textarea for post content
//    - Avatar of the current user next to the textarea
//    - Image upload button (optional)
//    - Post Button that calls createPost Server Action
//    - Character count indicator
// 2. A user profile page at app/profile/[username]/page.tsx with:
//    - Server Component that fetches the user's profile and posts
//    - Avatar, display_name, username, and bio at the top
//    - Post count, likes received total
//    - Their posts displayed in Card components identical to the feed
//    - Use Tabs to switch between Posts and Liked posts
// 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 example

File: `app/api/feed/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const cursor = searchParams.get('cursor')
  const limit = 20

  let query = supabase
    .from('posts')
    .select(
      '*, profiles(username, display_name, avatar_url)'
    )
    .order('created_at', { ascending: false })
    .limit(limit + 1)

  if (cursor) {
    query = query.lt('created_at', cursor)
  }

  const { data: posts, error } = await query

  if (error) {
    return NextResponse.json(
      { error: error.message },
      { status: 500 }
    )
  }

  const hasMore = (posts?.length ?? 0) > limit
  const items = hasMore ? posts!.slice(0, limit) : posts!
  const nextCursor = hasMore
    ? items[items.length - 1].created_at
    : null

  return NextResponse.json({
    posts: items,
    nextCursor,
  })
}
```

## Common mistakes

- **Using offset-based pagination (OFFSET 20, 40, 60...) for the feed** — Offset pagination gets slower as you go deeper because the database must skip all previous rows. At page 50 with 1000 rows per page, it skips 49,000 rows every request. New posts shift all offsets. Fix: 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.
- **Updating like counts with separate SELECT + UPDATE instead of atomic RPC** — If two users like the same post simultaneously, both read likes_count=5, both write likes_count=6, and one like is lost. This race condition becomes common with popular posts. Fix: Create Supabase RPC functions that use SET likes_count = likes_count + 1 (atomic increment) instead of reading and writing separately.
- **Not enabling Supabase Realtime on the posts table** — The Realtime subscription silently fails without the table added to the replication publication. New posts never appear in the feed without manual refresh. Fix: 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

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

---

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