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

Dedsec

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

  1. Introduction
  2. Setting Up the Backend with Supabase
  3. Initializing the Frontend with Vite
  4. Building the Core Components
  5. Final Thoughts
  6. 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

  1. Create a Supabase Project:
    Sign up at supabase.io and create a new project.

  2. 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.
  3. Enable Row-Level Security (RLS):
    Ensure RLS is enabled on your tables and set up appropriate policies.

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

  1. Initialize a Vite Project:
npm create vite@latest community-forum --template react-ts
cd community-forum
npm install
  1. Install Dependencies:
npm install @supabase/supabase-js @tanstack/react-query react-router-dom tailwindcss postcss autoprefixer
npx tailwindcss init -p
  1. 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;
  1. 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

Happy coding and enjoy building your community forum & Q&A site!