Build a Fullstack Social Media Site with Supabase, React, Vite, and Tailwind CSS

Pedro Machado / March 06, 2025
11 min read •
Description
Step-by-step tutorial for building a modern community forum with real-time features, glassy UI elements, gradient glows, and responsive design.
In this tutorial, we’ll build a full-featured community forum and Q&A site using Supabase, React, Vite, and Tailwind CSS. By following along step-by-step, you’ll learn how to set up user authentication (via GitHub), create posts with image uploads, implement real-time voting and commenting, and organize posts by communities. The finished project boasts a sleek, glassy UI with cool gradient and glow effects, and a fully responsive design.
Table of Contents
- Introduction
- Setting Up the Backend with Supabase
- Initializing the Frontend with Vite
- Building the Core Components
- Final Thoughts
- Additional Resources
Introduction
In this article, we will build a community forum and Q&A site with a modern design. Key features include:
- GitHub Authentication: Secure sign-in using GitHub, with user avatars and usernames.
- Post Creation & Image Uploads: Authenticated users can create posts that include text and images.
- Dynamic Voting & Commenting: Real-time likes, dislikes, and threaded commenting.
- Community Organization: Posts are grouped into communities for a Reddit-like experience.
- Modern Glassmorphic UI: Beautiful, glassy post cards with a subtle metallic background and glowing gradient borders on hover.
- Responsive Design: A fully responsive layout that works great on both mobile and desktop.
Let’s dive into building each piece of this project!
Setting Up the Backend with Supabase
-
Create a Supabase Project:
Sign up at supabase.io and create a new project. -
Configure Your Database:
Create the following tables in Supabase:- posts: Contains columns such as
id
,title
,content
,created_at
,image_url
,user_avatar_url
, etc. - votes: Contains columns such as
id
,post_id
,user_id
,vote
. - comments: Contains columns such as
id
,post_id
,parent_comment_id
,content
,user_id
,created_at
. - communities: Contains columns such as
id
,name
,description
.
- posts: Contains columns such as
-
Enable Row-Level Security (RLS):
Ensure RLS is enabled on your tables and set up appropriate policies. -
RPC Function:
Create the following RPC function in your Supabase SQL editor to fetch posts with like and comment counts, along with the user avatar URL:
CREATE OR REPLACE FUNCTION get_posts_with_counts()
RETURNS TABLE (
id integer,
title text,
content text,
created_at timestamptz,
image_url text,
like_count integer,
comment_count integer,
user_avatar_url text
)
LANGUAGE sql
AS $$
SELECT
p.id,
p.title,
p.content,
p.created_at,
p.image_url,
(SELECT COUNT(*) FROM votes v WHERE v.post_id = p.id) AS like_count,
(SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id) AS comment_count,
p.user_avatar_url
FROM posts p
ORDER BY p.created_at DESC;
$$;
Initializing the Frontend with Vite
- Initialize a Vite Project:
npm create vite@latest community-forum --template react-ts
cd community-forum
npm install
- Install Dependencies:
npm install @supabase/supabase-js @tanstack/react-query react-router-dom tailwindcss postcss autoprefixer
npx tailwindcss init -p
- Configure Tailwind CSS:
Update your.tailwind.config.js
file to include all your source files. Then add Tailwind’s directives to your CSS:
@tailwind base;
@tailwind components;
@tailwind utilities;
- Environment Variables:
Create a.env
file with your Supabase URL and anon key:
VITE_SUPABASE_URL=https://your-supabase-url.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
Building the Core Components
PostItem Component
The PostItem component displays an individual post as a card. It features a metallic grey background, a glassy look on hover, and a glowing gradient border that appears just outside the card.
import React from "react";
import { Link } from "react-router-dom";
// Define your Post type
export type Post = {
id: number;
title: string;
content: string;
created_at: string;
image_url: string;
avatar_url?: string;
like_count?: number;
comment_count?: number;
};
interface PostItemProps {
post: Post;
}
const PostItem: React.FC<PostItemProps> = ({ post }) => {
return (
<div className="relative group">
{/* Glow effect just outside the border */}
<div className="absolute -inset-1 rounded-[20px] bg-gradient-to-r from-pink-600 to-purple-600 blur-sm opacity-0 group-hover:opacity-75 transition duration-300 pointer-events-none"></div>
<Link to={`/post/${post.id}`} className="block relative z-10">
<div className="w-80 h-76 bg-[rgb(24,27,32)] border border-[rgb(84,90,106)] rounded-[20px] text-white flex flex-col p-5 overflow-hidden transition-colors duration-300 group-hover:bg-gray-800">
{/* Header: Avatar & Title */}
<div className="flex items-center space-x-2">
{post.avatar_url ? (
<img
src={post.avatar_url}
alt="User Avatar"
className="w-[35px] h-[35px] rounded-full object-cover"
/>
) : (
<div className="w-[35px] h-[35px] rounded-full bg-gradient-to-tl from-[#8A2BE2] to-[#491F70]" />
)}
<div className="flex flex-col flex-1">
<div className="text-[20px] leading-[22px] font-semibold mt-2">
{post.title}
</div>
</div>
</div>
{/* Image Preview */}
<div className="mt-2 flex-1">
{post.image_url ? (
<img
src={post.image_url}
alt={post.title}
className="w-full rounded-[20px] object-cover max-h-[150px] mx-auto"
/>
) : (
<div className="w-full rounded-[20px] bg-gradient-to-tl from-[#8A2BE2] to-[#491F70] max-h-[150px] mx-auto" />
)}
</div>
{/* Comment & Like Section */}
<div className="flex justify-around items-center">
<span className="cursor-pointer h-10 w-[50px] px-1 flex items-center justify-center font-extrabold rounded-lg">
❤️ <span className="ml-2">{post.like_count ?? 0}</span>
</span>
<span className="cursor-pointer h-10 w-[50px] px-1 flex items-center justify-center font-extrabold rounded-lg">
💬 <span className="ml-2">{post.comment_count ?? 0}</span>
</span>
</div>
</div>
</Link>
</div>
);
};
export default PostItem;
PostList Component
The PostList component fetches posts from Supabase and displays them in a responsive grid layout. On larger screens, the posts are arranged in three columns.
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "../supabaseClient";
import PostItem, { Post } from "./PostItem";
const fetchPosts = async (): Promise<Post[]> => {
const { data, error } = await supabase
.from("posts")
.select("*")
.order("created_at", { ascending: false });
if (error) throw new Error(error.message);
return data as Post[];
};
const PostList: React.FC = () => {
const { data, error, isLoading } = useQuery<Post[], Error>({
queryKey: ["posts"],
queryFn: fetchPosts,
});
if (isLoading)
return <div className="text-center py-4">Loading posts...</div>;
if (error)
return (
<div className="text-center text-red-500 py-4">
Error: {error.message}
</div>
);
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.map((post) => (
<PostItem key={post.id} post={post} />
))}
</div>
);
};
export default PostList;
CommentSection Component
The CommentSection component fetches and displays comments for a post. It also supports posting new comments and nested replies.
import React, { useState } from "react";
import { supabase } from "../supabaseClient";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../AuthContext";
import CommentItem from "./CommentItem";
export interface Comment {
id: number;
post_id: number;
parent_comment_id: number | null;
content: string;
user_id: string;
created_at: string;
// Optionally, include username if you join the profiles table
username?: string;
}
interface CommentSectionProps {
postId: number;
}
const fetchComments = async (postId: number): Promise<Comment[]> => {
// Joining with profiles table to fetch the username if available.
const { data, error } = await supabase
.from("comments")
.select("*, profiles(username)")
.eq("post_id", postId)
.order("created_at", { ascending: true });
if (error) throw new Error(error.message);
return (data as any[]).map((comment) => ({
...comment,
username: comment.profiles ? comment.profiles.username : null,
}));
};
const CommentSection: React.FC<CommentSectionProps> = ({ postId }) => {
const { user } = useAuth();
const queryClient = useQueryClient();
const {
data: comments,
error,
isLoading,
} = useQuery<Comment[], Error>({
queryKey: ["comments", postId],
queryFn: () => fetchComments(postId),
refetchInterval: 5000,
});
const mutation = useMutation({
mutationFn: async (newComment: {
content: string;
parent_comment_id?: number | null;
}) => {
if (!user) throw new Error("You must be logged in to comment");
const { error } = await supabase.from("comments").insert({
post_id: postId,
content: newComment.content,
parent_comment_id: newComment.parent_comment_id || null,
user_id: user.id,
});
if (error) throw new Error(error.message);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["comments", postId] });
},
});
const [newCommentText, setNewCommentText] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!newCommentText) return;
mutation.mutate({ content: newCommentText, parent_comment_id: null });
setNewCommentText("");
};
if (isLoading)
return <div className="text-center py-4">Loading comments...</div>;
if (error)
return (
<div className="text-center text-red-500 py-4">
Error loading comments: {error.message}
</div>
);
// Build a tree from the flat list of comments
const buildCommentTree = (
flatComments: Comment[]
): (Comment & { children?: Comment[] })[] => {
const map = new Map<number, Comment & { children?: Comment[] }>();
const roots: (Comment & { children?: Comment[] })[] = [];
flatComments.forEach((comment) => {
map.set(comment.id, { ...comment, children: [] });
});
flatComments.forEach((comment) => {
if (comment.parent_comment_id) {
const parent = map.get(comment.parent_comment_id);
if (parent) {
parent.children!.push(map.get(comment.id)!);
}
} else {
roots.push(map.get(comment.id)!);
}
});
return roots;
};
const commentTree = comments ? buildCommentTree(comments) : [];
return (
<div className="mt-6">
<h3 className="text-2xl font-semibold mb-4">Comments</h3>
{user ? (
<form onSubmit={handleSubmit} className="mb-4">
<textarea
value={newCommentText}
onChange={(e) => setNewCommentText(e.target.value)}
className="w-full border border-white/10 bg-transparent p-2 rounded"
placeholder="Write a comment..."
rows={3}
/>
<button
type="submit"
className="mt-2 bg-purple-500 text-white px-4 py-2 rounded cursor-pointer"
>
{mutation.isPending ? "Posting..." : "Post Comment"}
</button>
{mutation.isError && (
<p className="text-red-500 mt-2">Error posting comment.</p>
)}
</form>
) : (
<p className="mb-4 text-gray-600">
You must be logged in to post a comment.
</p>
)}
<div className="space-y-4">
{commentTree.map((comment) => (
<CommentItem key={comment.id} comment={comment} postId={postId} />
))}
</div>
</div>
);
};
export default CommentSection;
VoteButtons Component
The VoteButtons component enables users to like or dislike a post using thumbs up and down buttons. Selected buttons get a subtle white glow.
import React from "react";
import { supabase } from "../supabaseClient";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../AuthContext";
interface Vote {
id: number;
post_id: number;
user_id: string;
vote: number; // 1 for like, -1 for dislike
}
interface VoteButtonsProps {
postId: number;
}
const fetchVotes = async (postId: number): Promise<Vote[]> => {
const { data, error } = await supabase
.from("votes")
.select("*")
.eq("post_id", postId);
if (error) throw new Error(error.message);
return data as Vote[];
};
const VoteButtons: React.FC<VoteButtonsProps> = ({ postId }) => {
const { user } = useAuth();
const queryClient = useQueryClient();
const {
data: votes,
error,
isLoading,
} = useQuery<Vote[], Error>({
queryKey: ["votes", postId],
queryFn: () => fetchVotes(postId),
refetchInterval: 5000,
});
const mutation = useMutation({
mutationFn: async (voteValue: number) => {
if (!user) throw new Error("You must be logged in to vote");
const { data: existingVote } = await supabase
.from("votes")
.select("*")
.eq("post_id", postId)
.eq("user_id", user.id)
.maybeSingle();
if (existingVote) {
if (existingVote.vote === voteValue) {
const { error } = await supabase
.from("votes")
.delete()
.eq("id", existingVote.id);
if (error) throw new Error(error.message);
} else {
const { error } = await supabase
.from("votes")
.update({ vote: voteValue })
.eq("id", existingVote.id);
if (error) throw new Error(error.message);
}
} else {
const { error } = await supabase
.from("votes")
.insert({ post_id: postId, user_id: user.id, vote: voteValue });
if (error) throw new Error(error.message);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["votes", postId] });
},
});
if (isLoading) return <div>Loading votes...</div>;
if (error)
return (
<div className="text-red-500">Error loading votes: {error.message}</div>
);
const likes = votes?.filter((v) => v.vote === 1).length || 0;
const dislikes = votes?.filter((v) => v.vote === -1).length || 0;
const userVote = votes?.find((v) => v.user_id === user?.id)?.vote;
const baseClasses =
"px-3 py-1 rounded transition-all duration-150 bg-gray-200 text-black";
const selectedShadow = "shadow-[0_0_10px_rgba(255,255,255,0.5)]";
return (
<div className="flex items-center space-x-4 my-4">
<button
onClick={() => mutation.mutate(1)}
className={`${baseClasses} ${userVote === 1 ? selectedShadow : ""}`}
disabled={!user}
>
👍 {likes}
</button>
<button
onClick={() => mutation.mutate(-1)}
className={`${baseClasses} ${userVote === -1 ? selectedShadow : ""}`}
disabled={!user}
>
👎 {dislikes}
</button>
</div>
);
};
export default VoteButtons;
CommunityPage Component
Finally, the CommunityPage component displays posts from a specific community in the same grid style as the Home page.
import React from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "../supabaseClient";
import PostItem from "../components/PostItem";
import { Post } from "../components/PostDetail";
const fetchCommunityPosts = async (communityId: number): Promise<Post[]> => {
const { data, error } = await supabase
.from("posts")
.select("*, communities(name)")
.eq("community_id", communityId)
.order("created_at", { ascending: false });
if (error) throw new Error(error.message);
return data as Post[];
};
const CommunityPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const communityId = Number(id);
const { data, error, isLoading } = useQuery<Post[], Error>({
queryKey: ["communityPosts", communityId],
queryFn: () => fetchCommunityPosts(communityId),
});
if (isLoading)
return <div className="text-center py-4">Loading posts...</div>;
if (error)
return (
<div className="text-center text-red-500 py-4">
Error: {error.message}
</div>
);
return (
<div className="pt-20">
<h2 className="text-6xl font-bold mb-6 text-center bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent">
Community Posts
</h2>
{data && data.length > 0 ? (
<div className="max-w-2xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.map((post) => (
<PostItem key={post.id} post={post} />
))}
</div>
) : (
<p className="text-center text-gray-400">
No posts in this community yet.
</p>
)}
</div>
);
};
export default CommunityPage;
Final Thoughts
By following this tutorial, you’ve built a modern community forum & Q&A site that features:
- Secure GitHub authentication with user avatars and usernames.
- Dynamic post creation with image uploads.
- A real-time voting system using thumbs up and down buttons.
- A robust commenting system with threaded replies.
- Community categorization, allowing posts to be grouped by topic.
- A beautiful glassy, metallic UI with gradient glow effects.
- A fully responsive design using Vite, React, and Tailwind CSS.
This project demonstrates the power of modern web development tools and provides a great foundation to expand upon.
Additional Resources
- Watch the Tutorial on YouTube
- Supabase Documentation
- React Documentation
- Vite Documentation
- Tailwind CSS Documentation
Happy coding and enjoy building your community forum & Q&A site!