How to Build a Social Media Platform with Next.js and Supabase
Social media platforms are among the most complex web applications to build. User profiles, content feeds, likes, comments, followers, notifications, and real-time updates require careful architecture. But with modern tools like Next.js 14 and Supabase, you can build a production-ready social platform faster than ever.
This guide covers the core features of a social media application: authentication, user profiles, feed rendering, social interactions (likes, comments, follows), and real-time notifications.
Core Schema for Social Media
-- User profiles (extends auth.users)
create table profiles (
id uuid references auth.users on delete cascade primary key,
username text unique not null,
display_name text,
bio text,
avatar_url text,
cover_url text,
website text,
created_at timestamptz default now()
);
-- Posts
create table posts (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users not null,
content text not null,
image_url text,
created_at timestamptz default now()
);
-- Likes
create table likes (
post_id uuid references posts on delete cascade,
user_id uuid references auth.users on delete cascade,
created_at timestamptz default now(),
primary key (post_id, user_id)
);
-- Comments
create table comments (
id uuid default gen_random_uuid() primary key,
post_id uuid references posts on delete cascade not null,
user_id uuid references auth.users not null,
content text not null,
created_at timestamptz default now()
);
-- Follows
create table follows (
follower_id uuid references auth.users on delete cascade,
following_id uuid references auth.users on delete cascade,
created_at timestamptz default now(),
primary key (follower_id, following_id)
);
-- Enable real-time
alter publication supabase_realtime add table posts;
alter publication supabase_realtime add table likes;
alter publication supabase_realtime add table comments;Server Component Feed
// app/feed/page.tsx
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
import { FeedPost } from './FeedPost'
import { CreatePost } from './CreatePost'
export default async function FeedPage() {
const session = await auth()
if (!session?.user) redirect('/login')
const followingIds = await prisma.follows.findMany({
where: { follower_id: session.user.id },
select: { following_id: true },
})
const followingIdsList = followingIds.map(f => f.following_id)
followingIdsList.push(session.user.id) // Include own posts
const posts = await prisma.posts.findMany({
where: { user_id: { in: followingIdsList } },
include: {
user: { select: { username: true, display_name: true, avatar_url: true } },
_count: { select: { likes: true, comments: true } },
},
orderBy: { created_at: 'desc' },
take: 20,
})
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<CreatePost userId={session.user.id} />
<div className="mt-6 space-y-6">
{posts.map(post => (
<FeedPost key={post.id} post={post} userId={session.user.id} />
))}
</div>
</div>
)
}Like Button with Optimistic Updates
'use client'
import { useState, useTransition } from 'react'
import { Heart } from 'lucide-react'
import { toggleLike } from '@/app/actions/toggle-like'
export function LikeButton({
postId,
initialLiked,
initialCount,
}: {
postId: string
initialLiked: boolean
initialCount: number
}) {
const [liked, setLiked] = useState(initialLiked)
const [count, setCount] = useState(initialCount)
const [isPending, startTransition] = useTransition()
const handleClick = () => {
startTransition(async () => {
setLiked(!liked)
setCount(c => liked ? c - 1 : c + 1)
await toggleLike(postId)
})
}
return (
<button
onClick={handleClick}
disabled={isPending}
className="flex items-center gap-1.5 text-sm"
>
<Heart
className={`h-5 w-5 ${
liked ? 'fill-red-500 text-red-500' : 'text-gray-400'
}`}
/>
<span>{count}</span>
</button>
)
}Follow/Unfollow with Server Action
// app/actions/toggle-follow.ts
'use server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function toggleFollow(targetUserId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
if (session.user.id === targetUserId) throw new Error('Cannot follow yourself')
const existing = await prisma.follows.findUnique({
where: {
follower_id_following_id: {
follower_id: session.user.id,
following_id: targetUserId,
},
},
})
if (existing) {
await prisma.follows.delete({
where: { id: existing.id },
})
} else {
await prisma.follows.create({
data: {
follower_id: session.user.id,
following_id: targetUserId,
},
})
}
revalidatePath(`/profile/${targetUserId}`)
}Real-Time Notifications
'use client'
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { Bell } from 'lucide-react'
export function NotificationBell({ userId }: { userId: string }) {
const [count, setCount] = useState(0)
useEffect(() => {
const channel = supabase
.channel('notifications')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${userId}`,
},
() => {
setCount(c => c + 1)
}
)
.subscribe()
return () => supabase.removeChannel(channel)
}, [userId])
return (
<button className="relative">
<Bell className="h-5 w-5" />
{count > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
{count}
</span>
)}
</button>
)
}The Bottom Line
Social media platforms are complex but follow established patterns. The Social Media Starter template provides profiles, feeds, likes, comments, follows, and real-time notifications — everything you need to launch the next social platform.
Related Template
Try this production-ready starter kit to build your project faster.
Online — 5
Alice 10:30 AM
Hey everyone! 👋
Bob 10:31 AM
Morning team!
Real-Time Chat App
Build Slack-like chat in hours
Ready to Build?
Get started with our production-ready starter kits and ship your project faster.
Browse Starter Kits