Ribbit

A feature-rich pixel-perfect clone of the social media site Reddit.com
Table of Contents

Quick Facts

  • Project title: Ribbit
  • Description: A feature-rich pixel-perfect clone of Reddit.
  • Tech stack:
    React
    TypeScript
    Redux
    Flask
    SQLAlchemy
    Flask-SocketIO
  • Features (click to toggle)
    • Users
    • Communities
    • Subscriptions
    • Posts
    • Comments
    • Post Votes
    • Comment Votes
    • Community Rules
    • Search
    • Recently Viewed Posts
    • Community Appearance
    • Followers
    • Favorite Users
    • Favorite Communities
    • Messages
    • Notifications
    • Live Chat
    • Image Uploads
  • Github Repo: Link
  • Live demo: Link
  • Tech docs: Download

Introduction

This is Ribbit, a fullstack, pixel-perfect clone of the popular social media website Reddit. With over 15 features, a well-designed UI, and an experience that feels complete for users, Ribbit looks and feels just like the original, down to the smallest of details. After months of development and hard work, Ribbit has transformed into one of the best Reddit clones on the Internet.

About Ribbit

Background

Ribbit has been in development for over a year. Initially the result of a graduation requirement of the intensive software engineering bootcamp I attended, it had just 6 core features when I presented it to my cohort: Users, Posts, Comments, Communities, Subscriptions, and Post Votes.

After graduation, I found myself enjoying the process of building Ribbit so much that I proceeded to continue work on it. Over time, Ribbit has been through many stages, and has been a steady source of learning for me. It is easily the biggest project I've ever built singlehandedly, and has evolved into something more than "just another clone" that I couldn't be more proud to share with you today.

Technology Stack

To bring Ribbit to life with the level of functionality and polish envisioned, I employed the following tech stack:

Frontend

  • React - Used for building reusable components and creating a dynamic, responsive user interface.
  • React-Router - Facilitates smooth navigation between different views without the need for full page reloads.
  • Redux - Manages the application's state in a predictable way, simplifying data flow and state debugging.
  • CSS3 - Utilizes modern styling techniques to create a visually appealing and responsive design across all devices, including the use of CSS variables.

Backend

  • Flask - Chosen for its lightweight and flexible nature, allowing rapid development and easy customization to meet Ribbit's specific backend needs.
  • SQLAlchemy - Provides a powerful ORM that simplifies database interactions and efficiently handles complex data relationships like users, posts, and comments.
  • Flask-SocketIO - Implemented to support real-time features such as live updates and instant notifications, enhancing user engagement on Ribbit.

Testing

  • Jest - Used for its fast and reliable JavaScript testing capabilities, ensuring Ribbit's frontend code remains robust and error-free.
  • React-Testing-Library - Facilitates testing of React components from the user's perspective, ensuring the interface behaves as expected.

Goals and Inspiration

The development of Ribbit was driven by a few core goals and inspirations, including:

  1. A sandbox for learning - I've used Reddit for years, and as a user, I've always enjoyed how seamlessly its UI handles everything. I wanted to replicate many of its features - not by copying code, but by truly understanding and implementing the logic. Ribbit gradually evolved into my personal sandbox, a place where I could break things, fix them, and come out the other side with a deeper knowledge of full-stack development.
  2. Providing a complete user experience - Although Ribbit is a demo of an existing website, one of my primary goals was to provide enough functionality that it felt like a real, independent entity. While I could have just developed Reddit's core features, I knew that expanding beyond that would make the site feel more complete, as well as provide the user with a better experience overall.
  3. Showcasing my skills - I don't have years of professional experience or a formal degree, so I wanted a project that could demonstrate my capabilities. Ribbit wasn't about building a minimal viable product; it was about pushing myself to implement real features that people actually use on a day-to-day basis. The more challenging a feature, the more intrigued I was to try and build it.
  4. Polish and detail - I wanted to go beyond the standard "MVP clone" approach and really nail the small stuff, including intuitive hover states, informative tooltips, and a perfect replication of Reddit's interface.
  5. Future-proofing my skills - I knew this project could be a good chance to learn modern, in-demand technologies. Instead of sticking to what I already knew, I dove into frameworks, libraries, and APIs that were new to me, sometimes struggling, but always eventually coming out on top with more knowledge and experience than before. In doing so, Ribbit evolved from a side project into a comprehensive learning adventure, sharpening both my technical and problem-solving skills in a real-world context.

Ribbit wasn't just another clone project. It has served as my way of experimenting, growing, and showcasing what I'm capable of as a developer. Each piece of Ribbit was driven by a desire to learn, to improve, and to create a platform that people genuinely enjoy using, and I am proud to be able to present it to you here on my website.

Challenges

Like all projects, Ribbit's development wasn't necessarily a simple walk in the park; in fact, there were quite a few trials and tribulations that I had to face and traverse in order to produce the result you see today. While it would be impossible to list all of them, here are just some of the bigger ones.

When "Backend First" Backfires

During the first week of the project, I developed my features by separating the backend from the frontend. In other words, I would write the backend for multiple features before touching the frontend. That approach quickly fell apart - the website was seriously buggy, and debugging it was a nightmare due to code so entangled that fixing one thing meant breaking a few others.

Backend vs. Frontend mindmap

As a result, progress stalled. Unfortunately, I had a deadline, and time was running out. Thus, one week in, I made the difficult decision to scrap everything I'd built, saving only the CSS, and start from scratch. This time, I switched to developing one full feature at a time, front to back. This change was vital, and resulted in Ribbit not only being exponentially less buggy, but, more importantly, possible to debug.

Feature by Feature mindmap

Cleaning Up the Codebase

As Ribbit grew, so, too, did the chaos within its codebase. At some point, I realized I was writing my code to make it work, completely ignoring its quality. Upon this realization, I hit pause and launched a major refactor effort: modularized component, the usage of CSS variables, meaningful Git commits, and adherence to best practices.

Now, instead of dreading 500-line files writhe with duplications, multiple responsibiltiies, and not a custom hook in sight, I can be - and am - proud of the structure, readability, and maintainability of my codebase.

Let's see an example, shall we? This is one of Ribbit's components, SinglePost, before the Great Ribbit Refactor of 2024:

import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useHistory } from "react-router-dom";
import { Modal } from "../../../context/Modal";
import moment from "moment";
import DeleteConfirmation from "../../Modals/DeleteConfirmation";
import "./SinglePost.css";
import UpdatePost from "../PostForms/UpdatePost";
import { NavLink } from "react-router-dom";
import { getComments } from "../../../store/comments";
import { getCommunities } from "../../../store/communities";
import { getCommunityPosts, getPosts } from "../../../store/posts";
import Bounce from "../../../images/curved-arrow.png";
import { getSinglePost } from "../../../store/one_post";
import { addPostVote, removePostVote } from "../../../store/posts";

export default function SinglePost({ id, isPage }) {
  const history = useHistory();
  const dispatch = useDispatch();
  const post = useSelector((state) => state.posts[id]);
  const user = useSelector((state) => state.session.user);
  const community = useSelector(
    (state) => state.communities[post?.communityId]
  );
  const [showLinkCopied, setShowLinkCopied] = useState(false);
  const [showDeleteModal, setShowDeleteModal] = useState(false);
  const [showEditModal, setShowEditModal] = useState(false);
  const [voteAllowed, setVoteAllowed] = useState(false);

  useEffect(() => {
    dispatch(getComments(id));
    dispatch(getCommunities());
    dispatch(getPosts());
    dispatch(getSinglePost(id));
    if (showLinkCopied) {
      setTimeout(() => {
        setShowLinkCopied(false);
      }, 3000);
    }
  }, [dispatch, id, showLinkCopied]);

  const displayLikes = (likes) => {
    const keys = Object.keys(likes);
    if (keys.length > 1 && user.id in likes) {
    }
  };

  const handleAddVote = async () => {
    await dispatch(addPostVote(post.id));
  };

  const handleRemoveVote = async () => {
    await dispatch(removePostVote(post.id));
  };

  useEffect(() => {
    if (post && post.postVoters) {
      let postVoters = Object.values(post.postVoters);
      if (postVoters.length === 0) {
        setVoteAllowed(true);
      } else {
        for (let voter of postVoters) {
          if (voter?.username === user?.username) {
            setVoteAllowed(false);
            break;
          } else {
            setVoteAllowed(true);
          }
        }
      }
    }
  }, [voteAllowed, post?.postVoters]);

  if (!post || !post.postVoters || !Object.values(post.postVoters)) return null;
  return (
    <>
      {post && (
        <div className="single-post-container">
          <div className="single-post-karmabar">
            <button
              className={
                user?.id in post?.postVoters ? "vote-btn-red" : "vote-btn-grey"
              }
              onClick={
                user?.id in post?.postVoters ? handleRemoveVote : handleAddVote
              }
            >
              <i className="fa-solid fa-thumbs-up"></i>
            </button>
            <span className="karmabar-votes">{post.votes}</span>
          </div>
          <div className="single-post-main">
            <div className="single-post-author-bar">
              {isPage !== "community" && (
                <div className="single-post-community-info">
                  <div className="single-post-community-img">
                    <img src={community?.communityImg} />
                  </div>
                  <div className="single-post-community-name">
                    <NavLink to={`/c/${community?.id}`}>
                      c/{community?.name}
                    </NavLink>
                  </div>
                  <span className="single-post-dot-spacer">•</span>
                </div>
              )}

              <div className="single-post-author-info">
                Posted by{" "}
                <NavLink to={`/users/${post.postAuthor.id}`}>
                  u/{post.postAuthor.username}
                </NavLink>{" "}
                {moment(new Date(post.createdAt)).fromNow()}
              </div>
            </div>
            <div className="single-post-title-bar">{post.title}</div>
            {post.imgUrl ? (
              <div className="single-post-content-image">
                <img className="image-post-img" src={post.imgUrl} />
              </div>
            ) : (
              <>
                {isPage === "all" && (
                  <div className="single-post-content">{post.content}</div>
                )}
                {isPage === "singlepage" && (
                  <div className="single-page-content">{post.content}</div>
                )}
                {isPage === "community" && (
                  <div className="single-post-content">{post.content}</div>
                )}
                {isPage !== "singlepage" &&
                  isPage !== "all" &&
                  isPage !== "community" && (
                    <div className="single-post-content">{post.content}</div>
                  )}
              </>
            )}

            <div className="single-post-button-bar">
              <div className="single-post-button">
                <button className="single-post-comments-btn">
                  <i className="fa-regular fa-message"></i>{" "}
                  <span className="single-post-comments-num">
                    {Object.values(post.postComments).length || 0}{" "}
                    {Object.values(post.postComments).length === 1
                      ? "Comment"
                      : "Comments"}
                  </span>
                </button>
              </div>
              <div className="share-btn-stuff">
                {isPage === undefined && (
                  <NavLink to="/">
                    <div className="single-post-button">
                      <button
                        className="single-post-share-btn"
                        onClick={() => {
                          setShowLinkCopied(true);
                          navigator.clipboard.writeText(
                            `https://ribbit-app.herokuapp.com/posts/${post.id}`
                          );
                        }}
                      >
                        <img src={Bounce} className="single-post-share-icon" />
                        Share
                      </button>
                    </div>
                  </NavLink>
                )}
                {isPage === "all" && (
                  <NavLink to="/c/all">
                    <div className="single-post-button">
                      <button
                        className="single-post-share-btn"
                        onClick={() => {
                          setShowLinkCopied(true);
                          navigator.clipboard.writeText(
                            `https://ribbit-app.herokuapp.com/posts/${post.id}`
                          );
                        }}
                      >
                        <img src={Bounce} className="single-post-share-icon" />
                        Share
                      </button>
                    </div>
                  </NavLink>
                )}
                {isPage === "community" && (
                  <NavLink to={`/c/${post.communityId}`}>
                    <div className="single-post-button">
                      <button
                        className="single-post-share-btn"
                        onClick={(e) => {
                          e.preventDefault();
                          setShowLinkCopied(true);
                          navigator.clipboard.writeText(
                            `https://ribbit-app.herokuapp.com/posts/${post.id}`
                          );
                        }}
                      >
                        <img src={Bounce} className="single-post-share-icon" />
                        Share
                      </button>
                    </div>
                  </NavLink>
                )}

                {isPage === "singlepage" && (
                  <div className="single-post-button">
                    <button
                      className="single-post-share-btn"
                      onClick={() => {
                        setShowLinkCopied(true);
                        navigator.clipboard.writeText(
                          `https://ribbit-app.herokuapp.com/posts/${post.id}`
                        );
                      }}
                    >
                      <img src={Bounce} className="single-post-share-icon" />
                      Share
                    </button>
                  </div>
                )}
                {showLinkCopied && (
                  <div
                    className={
                      showLinkCopied
                        ? "animate-mount tooltiptext"
                        : "animate-unmount tooltiptext"
                    }
                  >
                    Link Copied to Clipboard
                  </div>
                )}
              </div>
              {isPage === "singlepage" &&
              user &&
              user.id === post.postAuthor.id ? (
                <div className="logged-in-btns">
                  <div className="single-post-button">
                    <button
                      className="single-post-edit-btn"
                      onClick={() => history.push(`/posts/${post.id}/edit`)}
                    >
                      <i className="fa-solid fa-pencil"></i>
                      Edit
                    </button>
                    {showEditModal && (
                      <Modal
                        onClose={() => setShowEditModal(false)}
                        title="Edit post"
                      >
                        <UpdatePost
                          setShowEditModal={setShowEditModal}
                          showEditModal={showEditModal}
                        />
                      </Modal>
                    )}
                  </div>
                  <div className="single-post-button">
                    <button
                      className="single-post-delete-btn"
                      onClick={(e) => {
                        e.preventDefault();
                        setShowDeleteModal(true);
                      }}
                    >
                      <i className="fa-regular fa-trash-can"></i>
                      Delete
                    </button>
                    {showDeleteModal && (
                      <Modal
                        onClose={() => setShowDeleteModal(false)}
                        title="Delete post?"
                      >
                        <DeleteConfirmation
                          showDeleteModal={showDeleteModal}
                          setShowDeleteModal={setShowDeleteModal}
                          postId={post.id}
                          communityId={community.id}
                          item="post"
                          post={post}
                        />
                      </Modal>
                    )}
                  </div>
                </div>
              ) : (
                ""
              )}
            </div>
          </div>
        </div>
      )}
    </>
  );
}
javascript
  • One file, many jobs (~280 LOC): UI rendering, data-fetching, voting rules, modals, share-link logic, navigation, and styling concerns, all entangled together.
  • Heavy re-renders and wasted calls: useEffect fired getPosts(), getCommunities(), and getComments() on every mount, even when those resources wree already in Redux, resulting in poor performance and long load times.
  • Out of control state: Six separate useState flags (showDeleteModal, voteAllowed, etc.) made flow hard to follow and increased bug surface.
  • Copy-pasted branches: Duplication in the form of four near-identical <NavLink />/share-button blocks for each isPage variant.
  • SRP and testability violated: Any changes to votes, modals, or routing forced edits in the same file. Mocking it in tests required a full Redux store and router context.
  • Side-effect coupling: Vote throtttling, clipboard writes, and timeout timers all lived inside the render component, blocking unit tests and complicating debugging.
  • Mixed styling and markup: Inline class juggling, plus a dedicated .css file, made the visual layer harder to discern.

Tsk, tsk. Terrible.

That's okay, because this is what SinglePost looks like today:

import { FC, useContext, useEffect } from "react";
import { useHistory } from "react-router-dom";
import { PostFormatContext } from "@/context";
import { RootState } from "@/store";

import CardPostFormat from "./CardPostFormat";
import ClassicPostFormat from "./ClassicPostFormat";
import CompactPostFormat from "./CompactPostFormat";

/* ───────────────────────── Types ───────────────────────── */

type Post = RootState["posts"][string];
type PageKind = "profile" | "singlepage" | string;

export interface SinglePostProps {
  link?: string;
  id: number | string;
  isPage?: PageKind;
  post: Post;
  handleCommentsBtnClick?: () => void;
}

/* ───────────────────────── Component ───────────────────── */

export const SinglePost: FC<SinglePostProps> = ({
  link,
  id,
  isPage,
  post,
  handleCommentsBtnClick,
}) => {
  const history = useHistory();
  const { format, setFormat } = useContext(PostFormatContext);

  /* initialise format once on mount */
  useEffect(() => {
    if (isPage === "singlepage") {
      setFormat("Card");
      return;
    }

    const stored = localStorage.getItem("selectedPostFormat") as
      | "Card"
      | "Classic"
      | "Compact"
      | null;

    setFormat(stored ?? "Card");
  }, [isPage, setFormat]);

  return (
    <article className="single-post">
      <span
        onClick={() => history.push(`/posts/${post.id}`)}
        /* Keep Card posts keyboard-focusable, others not */
        tabIndex={format !== "Card" ? -1 : undefined}
      >
        {(isPage === "profile" || format === "Card") && (
          <CardPostFormat
            post={post}
            handleCommentsBtnClick={handleCommentsBtnClick}
            link={link}
            isPage={isPage}
          />
        )}

        {isPage !== "profile" && format === "Classic" && (
          <ClassicPostFormat post={post} id={id} isPage={isPage} />
        )}

        {isPage !== "profile" && format === "Compact" && (
          <CompactPostFormat post={post} id={id} isPage={isPage} />
        )}
      </span>
    </article>
  );
};
javascript
  • Single responsibility: Now acts only as a format-aware presenter that chooses whether the format is "Card", "Classic", or "Compact", and wraps them in a navigable link.
  • Slimmed down (<100 LOC): All data fetching, voting logic, and modal state were moved to dedicated hooks and components.
  • TypeScript utilized: Uses TypeScript generics (RootState["posts"][string], SinglePostProps) so prop-shape errors surface at compile time.
  • Minimal side-effects: One tiny useEffect handles format initialization with graceful localStorage fallback. Everything else is pure render.
  • Better accessibility: Keeps keyboard focus on clickable cards only (tabIndex logic) and swaps imperative history.push for a declarative wrapper.
  • Easier to test: With no Redux or network calls inside, component tests can mount it with plain props and assert which sub-component renders.
  • Clean imports and styling: Import statements look concise and clean, and are much more readable and maintainable than before. All inline styling has been eliminated, leaving it to the stylesheets to handle instead.

Load Times from Hell

To make Ribbit feel alive, I bulk-seeded data such as users, posts, communities, and comments, all in one go. After introducing all of this data to the database, the app slowed to a painful crawl, and I was stuck with multi-minute load times.

I knew it couldn't really be the seeded data at fault; after all, in the grand scheme of things, Ribbit's database contains just a fraction of the data that other sites contain. I eventually discovered that I was correct, and that the root of this issue was not because of the seeded data, but because of poorly-written code.

The fix required performance profiling, lazy loading, optimized queries, and drastically reducing unnecessary re-renders. Ribbit is now fast, even with a full dataset.

Loading skeletons demonstration

This challenge also brought to my attention the lack of loading-based features, and led to the implementation of loading skeletons to help the user remain at ease during load times.

Learning WebSockets

Real-time chat was a non-negotiable feature for me, but I'd never worked with WebSockets before. Learning Flask-SocketIO meant diving into unfamiliar territory, experimenting, failing, and adjusting repeatedly. After a lot of trial and error, I got live chat and real-time notifications working reliably, and picked up a deep appreciation for async event handling along the way.

Perfecting Ribbit's UI

Since Ribbit is a Reddit clone, accuracy mattered. I went above and beyond to replicate Reddit's UI, paying attention to small details like hover states, tooltips, dropdowns, and keyboard accessibility. Although this process was a little time-consuming, the result was well worth it, being a frontend that doesn't just look like Reddit, but feels like it, too.

Traversing an Outdated Design

Over halfway through Ribbit's development, Reddit rolled out a site-wide redesign to modernize its look. Unfortunately, this meant that the UI I had spent months replicating was suddenly gone. I no longer had access to the design I was nearly finished implementing.

An example: Here's the Create Post page. The first image shows this page in Reddit's previous design (aka the one I was trying to implement), and the one after is in Reddit's new design (aka the one I'm forced to look at these days). Take a look at just how different these two designs are.

Create Post's previous design
Create Post's new design

To get over this hurdle, I turned to Google Images and dug up as many screenshots as I could from 2023 and earlier - sometimes in light mode, sometimes in dark, but rarely both. Despite the extra challenge, I managed to recreate nearly all of the original layout, with just a few minor details reflecting Reddit's new design. The biggest reflection of Reddit's new design shows when viewing Ribbit on a small screen ("mobile mode"), as The final result still captures the spirit and structure of the version I set out to build, and is still, by and large, a pixel-perfect replication.

Things I Would Do Differently: Important Lessons Learned

  1. Adopt TypeScript on Day 0: Adding static types at the very beginning pays dividends throughout the life of a codebase. When every component, hook, API call, and Redux slice is typed from the start, autocomplete becomes smarter, refactors are safer, and bugs are caught before they hit the browser. When I decided to add TypeScript to my codebase, I had to retrofit hundreds of .ts annotations after the fact, an effort that ballooned into days of "find-the-any" detective work.
  2. Start testing with the very first component: Tests written after MVP feel like pushing a boulder uphill: you're chasing down side effects, mocking half-finished APIs, and inevitably rewriting production code to make it testable. By contrast, adding a simple render-assertion for component #1 creates a safety net that naturally grows with the project. It also forces you to write code that's decoupled, deterministic, and accessible.
  3. Bake in established best practices from the beginning: "We'll clean it up later" usually translates to "we'll never clean it up." Principles like SRP, DRY, and clear folder boundaries aren't overhead, but guardrails that keep velocity high as complexity increases. Early shortcuts turned into tangled dependencies that slowed every new feature, and I ended up undergoing a massive effort to refactor the entire codebase in an effort to produce professional code, time that could have been spent much more productively.
  4. Ship when it's clear, not when it's perfect: Pixel-perfect UIs are great showpieces, but brittle CSS hacks and over-engineered components cost more in the long run than a minor visual quirk. I now aim for "readable, maintainable, scalable" as the definition of done. If the 90% solution is clean and the last 10% requires inscrutable magic numbers or duplicate logic, I'll ship the 90% and log a design-polish card for later.
  5. Stick with the plan: Don't deviate from the "MVP features" plan; the rest can be tackled later if there's time. This sort of discipline keeps deadlines realistic and prevents a mid-sprint existential crisis.
  6. Schedule periodic dependency upgrades: Upgrading from React 18.0.0 to 18.3.0 is easy; leaping from 16.x to 18.x mid-project is a mini-migration. Preventing a mid-development crisis is always preferrable.
  7. Capture metrics throughout: ...so they can be used later on for project data. Page-load times, API latency, Lighthouse scores, even simple Google Analytics events, tell a compelling story on a résumé or project showcase. Retrofitting telemetry after launch means you miss the "before" numbers that highlight your optimizations.
  8. Utililze CSS variables: Variables for color, spacing, and typography keep stylesheets DRY and enable effortless theming (dark mode, branding, A/B tests). Waiting until the CSS is sprawling makes extraction error-prone.
  9. Commit to mobile-first designs from the beginning: Designing desktop-first and "shrinking down" often yields unbalanced layouts and last-minute media-query spaghetti. A mobile-first approach forces you to prioritize content hierarchy and progressive enhancement.

Implementation Details

This section takes a more in-depth look at the details behind Ribbit's codebase, including specific code snippets, folder structures, the reasons behind certain decisions, etc.

Stack Overview

LayerTechnologiesDetails
BackendFlask, SQLAlchemy, Flask-SocketIOLightweight micro-framework, declarative ORM, shared session between HTTP and WebSockets.
FrontendReact, ReduxModern hooks API, batteries-included state slice pattern, lightning-fast dev server.
DataPostgreSQLReliable JSON + full-text search, easy Heroku/Render deploy.
ToolingDocker Composedocker compose up spins the whole stack — no "works on my machine" surprises.

Codebase Structure

ribbit/
├─ app/
│  ├─ api/              # Blueprints  (auth, posts, chat…)
│  ├─ models/           # SQLAlchemy ORM classes
│  ├─ forms/            # WTForms validators
│  ├─ seeds/
│  └─ socket.py         # Real-time event handlers
└─ frontend/
   └─ src/
      ├─ assets/
      ├─ components/
      ├─ context/
      ├─ features/      # Auth / Posts / Chat (self-contained)
	  │  └─ Auth/
	  │	    ├─ __tests__/
	  │	    ├─ components/
	  │	    ├─ data/
	  │	    ├─ hooks/
	  │	    ├─ styles/
	  │	    └─ utils/
      ├─ hooks/         # Reusable logic
      ├─ layouts/
      ├─ pages/
      ├─ routes/
      ├─ store/         # Redux slices
      └─ utils/
bash

Backend Highlights

Database Schema Diagram

Database schema diagram (dark)

This is the diagram of Ribbit's database schema. For an in-depth look, you can visit the schema wiki, where you can see all of the dirty details up close. There are 23 tables and too many relationships between them to count, showing just how advanced Ribbit is in comparison to your basic clone.

Backend Folder Structure

app/
├─ api/
├─ models/
├─ forms/
├─ seeds/
└─ socket.py
bash
  • api/ - All Flask Blueprints go here, each Blueprint exposing the endpoints for a specific feature area (users, posts, comments, etc.).
  • models/ - The entire data schema in one place: SQLAlchemy models, relationships, and mixins.
  • forms/ - Server-side validation via WTForms classes; keeps input sanitising logic out of the routes.
  • seeds/ - Seed scripts for populating the database.

Blueprint Example (Posts)

post_routes = Blueprint("posts", __name__)

@posts_routes.route("/")
def list_posts():
    return {"posts": [p.to_dict() for p in Post.query.all()]}
python

This snippet registers a Blueprint called posts_routes, giving every route inside its own URL namespace (e.g., /posts). The single route, "/", handles GET requests by querying every Post record, converting each to a plain-Python dictionary with to_dict(), and wrapping the result in a JSON-serializable object:

Form Validation Example (Posts)

# FORM FOR CREATING A POST
class PostForm(FlaskForm):
    title = TextAreaField(
        "Title",
        validators=[
            DataRequired("Please give your post a title."),
            Length(
                min=1,
                max=300,
                message="Please give your post a title. Titles are limited to 300 characters."
            ),
        ],
    )
    content = TextAreaField(
        "Content",
        validators=[
            Length(
                max=40000,
                message="Please give your post some content. Posts are limited to 40,000 characters.",
            )
        ],
    )
    communityId = IntegerField("CommunityId")
    submit = SubmitField("Submit")
python

PostForm is a Flask-WTF form that enforces basic sanity checks before a new post ever hits the database:

FieldTypeKey ValidatorsPurpose
"Title"TextAreaFieldDataRequired, Length(1-300)Guarantees every post has a non-empty title capped at 300 characters.
"Content"TextAreaFieldLength(max=40000)Optional body text, but limited so someone can't drop a novel in.
"CommunityId"IntegerFieldStores the chosen community's id, set by the user in the 'choose a community' dropdown.
"Submit"SubmitFieldRenders the Submit button.

Using field-level validators keeps error messages specific and user-friendly while centralising all post-creation rules in one declarative class.

Model Example (Viewed Posts)

from app.extensions import db
from datetime import datetime, timezone

class ViewedPost(db.Model):
    # Name the table
    __tablename__ = "viewed_posts"

    # Table attributes
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey("users.id"))
    post_id = db.Column(db.Integer, db.ForeignKey("posts.id"))
    timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))

    # Relationships with 'users' and 'posts'
    user = db.relationship("User", back_populates="viewed_posts")
    post = db.relationship("Post", back_populates="post_viewers")

    # converts to dictionary for use in backend
    def to_dict(self):
        return {
            "id": self.id,
            "userId": self.user_id,
            "postId": self.post_id,
            "timestamp": self.timestamp,
            "post": self.post.to_dict() if self.post is not None else None
        }

    # returns string form of table row, for debugging and logging purposes
    def __repr__(self):
        return f"<ViewedPost {self.id}: post id {self.post_id} by user id {self.user_id}>"
python

ViewedPost is a join table with extras; it logs every time a user opens a post, and it timestamps the event to display for the user.

  • Schema & relationships: Standard id primary key, plus two foreign keys (user_id and post_id). Bidirectional relationships (user.viewed_posts and post.post_viewers) make it trivial to ask "which posts has Alice seen?" or "who has viewed Post 42?".
  • timestamp default: Uses datetime.now(timezone.utc) via a lambda so the timestamp is computed server-side at insert time, not at import.
  • Helper methods: to_dict() returns an API-ready payload, optionally nesting the post's own dictionary, while __repr__ makes debugging output readable (<ViewedPost 17: post 42 by user 3>).

Since view events are isolated into their own model, posts and users aren't bloated with additional information.

Seed Data Example (Posts)

This excerpt comes from api/seeds/followers.py and is part of the function that populates each user's "followers" list with the ids of users to follow. In doing it this way, I am able to directly map out who is following who. Of course, if I didn't want so much control, randomizing a user's follows list could be an option, too.

def seed_followers():
    # Preload users 2‑50 once
    users = {u.id: u for u in User.query.filter(User.id.in_(range(2, 51))).all()}

    follow_map = {
        2:  [3, 4, 7, 10, 14, 19, 23, 25, 30, 31, 38, 42, 44, 50],
        3:  [2, 8, 11, 16, 21, 27, 33, 40, 46],
    }

    for follower_id, followed_ids in follow_map.items():
        users[follower_id].followed.extend(users[uid] for uid in followed_ids)

    db.session.commit()
python
  1. First, we fetch the relevant users (ids 2 through 50) in a single query.
  2. Next, we define follow_map, a concise dictionary that pairs each user with the set of accounts they should follow, keeping seed data readable without sprawling loops or hard-coded statements. In this excerpt, we are giving users 2 and 3 a small sample size of followed users. You'll notice that they're following each other, too.
  3. Once follow_map is defined, we loop over its items. For each entry, the dictionary key represents the follower's user id, while the value is a list of followed user ids.
  4. We use .extend()to append those target users to the follower's followed relationship.
  5. Finally, we commit the session to persist everything in one transaction.

Checking out socket.py

In the backend, all WebSockets traffic funnels through a single module, app/socket.py. Every socket is authenticated on connect, parked in its personal room and routed by a handful of event handlers that power live chat, live chat reactions, and notifications. Consolidating that logic into one file makes auditing and debugging simple.

Take, for example, this helper function, emit_notificaiton_to_user():

def emit_notification_to_user(notification):
    """
    Broadcast a newly-created Notification to its owner in real time.
    """
    room = f"user_{notification.user_id}"
    socketio.emit("new_notification", notification.to_dict(), to=room)
python

This simple two-liner, despite being small, is the linchpin that makes the notifications system feel instant.

  • room: Derives the room name from the id of the user the notification is for. Note that a "room" is simply a label.
  • SocketIO then fires a new_notification event. The notification in question is sent to the frontend, using the room to identify where the notification should go (or rather, whom it should go to).

A Tour of the Frontend

Best Practices

Ribbit's frontend components and files follow a general list of best practices.

  1. LOC Limit: If a file reaches 200 lines of code (LOC), that means that it's time to check on the file to see if it could use some shortening, typically via one of the following:
    • Don't Repeat Yourself (DRY): Keep components DRY. Use reusable components and custom hooks to eliminate unnecessary duplicated code.
    • Single Responsibility Principle (SRP): The goal is for each component "to do one thing, and do that one thing well". If a component starts taking on too many roles, cut it down into multiple smaller components.
    • Separation of Concerns (SoP): Separate the UI from its logic by extracting said logic into a custom hook. These custom hooks are named after the component for which it holds the logic (e.g. SinglePost and useSinglePost).
  2. Avoid prop drilling: Utilize the Context API to eliminate prop drilling.
  3. One prop object is better than several props: Avoid passing a lot of props, and instead pass one prop by turning the props into an object. More specifically, related props should go into their own object.
    <FormInput
    	name="username"
    	value={username}
    	onChange={(e) => setUsername(e.target.value)}
    	placeholder="Choose your username"
    	maxValue={255}
    	labelText="Username"
    />
    javascript
    const formInputProps = {
    	name: "username",
    	value: username,
    	onChange: (e) => setUsername(e.target.value),
    	placeholder: "Choose your username",
    	maxValue: 255,
    	labelText: "Username"
    }
    
    <FormInput inputProps={formInputProps} />
    javascript
  4. No inline code: Remove inline JavaScript and inline styles (CSS) to improve readability.
  5. Clean up: Remove console.logs, unnecessary comments, and unused variables, props, and imports.
  6. Keep import statements readable: Utilize barrels and aliases to shorten import paths and keep them concise.
  7. Organize code: Organize import statements and component logic; useState hooks should be with useState hooks, event handlers with event handlers, etc.
  8. Use Effects correctly: Follow React's You Might Not Need an Effect guide (which, in my opinion, should be required reading for all React developers) to use Effects correctly. This generally means that Effects are used much less often, improving code quality and performance.
  9. Avoid "magic" numbers and strings: A magic number/string is a number/string that "magically" appears with no prior context or inferred meaning, and hurts both readability and maintainability. An example:
    const totalTemp = todaysTemp + 180;
    javascript
    What is 180? Where did it come from? Removing this magic number would look something like this:
    const yesterdaysTemp = 180;
    const totalTemp = todaysTemp + yesterdaysTemp;
    javascript
    Now we know where the 180 comes from, we can use yesterdaysTemp in multiple places, and we can easily use the variable initialization to change the value needbe.

Frontend Folder Structure

While the backend has a small folder structure, the frontend, by contrast, has a rather large structure with a tad more complexity.

frontend/src/
├─ assets/
├─ components/
├─ context/
├─ features/
│  └─ Auth/
│	  ├─ __tests__/
│	  ├─ components/
│	  ├─ data/
│	  ├─ hooks/
│	  ├─ styles/
│	  └─ utils/
├─ hooks/
├─ layouts/
├─ pages/
├─ routes/
├─ store/
└─ utils/
bash

As shown above, Ribbit's frontend adopts a feature-based folder structure, meaning that although general/reusable components and functions are in shared folders like components/ and hooks/, all of a feature's files can be found colocated together inside of features/FeatureName/, where a smaller version of the frontend's folder structure keeps that feature's files similarly organized.

  • assets/: Contains images, icons, and global stylesheets.
  • components/: All of the general or reusable components, meaning ones that don't belong to a specific feature, but to the site as a whole.
  • context/: The Context API files, for the main purposes of reducing prop drilling and eliminating duplicated code in the frontend.
  • features/: Features and their respective files.
  • hooks/: General or reusable custom hooks, again not belonging to a specific feature, but to Ribbit itself.
  • layouts/: Houses layout-related components.
  • pages/: This folder contains one file per page on Ribbit. Each file serves as a place to glue together the components that make up that page, and doesn't contain anything more.
  • routes/: After App.js became long and unruly, I opted to move the routes to their own folder for the sake of cleaner, more maintainable code.
  • store/: All Redux-related files. Each feature has its own slice (i.e., posts.js), and this file is where the actions, the thunks, and a reducer for that feature live.
Feature-Specific Folders

Let's look at the Authentication feature as an example, located at features/Auth/. This feature is primarily responsible for the Log In and Sign Up forms and all of their unique nuances.

  • Auth/components/: Within this folder we would find the frontend components for the auth forms, as well as any additional components that are used to create them.
  • Auth/data/: This folder is more rarely found; for the Auth feature, it exists for the sole purpose of housing data for a "Random Username Generator" tool that the Sign Up form offers; specifically, a 'nouns' list and an 'adjectives' list.
  • Auth/hooks/: Over in this folder are all of the custom hooks meant for this feature alone.
  • Auth/utils/: Similarly, in here are the utility functions that are strictly meant for the Auth feature.
  • Auth/styles/: Although there are a couple of global stylesheets, most features have their own stylesheets for easier editing and debugging, and would be located here.
  • Auth/__tests__/: Finally, we have the folder that houses the tests for this feature. Although there are other options (like putting test files directly alongside the files they test), I wanted to keep this sort of folder pattern consistent throughout the frontend.
Barrels and Aliases

One of the potential downsides to breaking your components up into smaller components and housing them in nested folders is the potential for import statements to grow long and out of control.

To help manage this, I implemented the use of barrels and aliases. A barrel refers to an index.ts file in every directory that does nothing but re-export the other files in that directory, and an alias enables you to turn "dot ladders" like this: ../../../ into a short alias, like this: @/.

The results? Turning long and unruly import statements like this:

import PostFeed from "../../../features/Posts/components/PostFeed/PostFeed";
import PostFeedBtn from "../../../features/Posts/components/PostFeed/PostFeedBtn";
import ScrollToTop from "../../../components/ScrollToTop/ScrollToTop";
import useScrollToTop from "../../../hooks/useScrollToTop";
javascript

...into a much more concise and friendly version, like this:

import { PostFeed, PostFeedBtn } from "@/features";
import { ScrollToTop } from "@/components";
import { useScrollToTop } from "@/hooks";
javascript

Side note: If you're interested in learning more about utilizing barrels and aliases to manage your import statements, I have a blog post from my 'Levelling Up React' series that teaches you how to do so, and you can find it here.

Redux State Management

State management is handled by Redux. Each major feature has its own file in the store/ directory, named after the feature itself (e.g. the Posts feature's Redux file is posts.ts). Let's look at an example using Posts - and specifically, retrieving all posts - as said example.

First we have our action:

const LOAD_POSTS = "posts/LOAD";

export const loadPosts = (posts) => {
  return {
    type: LOAD_POSTS,
    posts,
  };
};
javascript

Next, our thunk:

export const getPosts = () => async dispatch => {
	const response = await fetch("/api/posts");
	if(response.ok) {
		const data = await response.json();
		dispatch(loadPosts(data));
		return data;
	}
}
javascript

And finally, the reducer:

const initialState = {};

export default function postsReducer(state = initialState, action) {
    case LOAD_POSTS:
      if (action.posts && action.posts.Posts) {
        return action.posts.Posts.reduce((posts, post) => {
          posts[post.id] = post;
          return posts;
        }, {});
      } else {
        return state;
      }
      default:
        return state;
}
javascript

Features

Ribbit was in development for a bit longer than I originally anticipated, resulting in me producing a wide array of features to make the site feel complete. This is quite a long list as a result, so buckle in!

Multiple Ways to Log In

Log In form

Site visitors (users not logged into an account) are able to browse Ribbit's posts, community pages, and user profiles, and are able to use the Search feature, but that's about it. There are several features and pages that site visitors aren't able to access, and they must log into an account to fully experience Ribbit in its entirety. There are three available options:

  1. Create a Ribbit account
  2. Use an existing Google account
  3. Log in as the Demo user, provided specifically for touring Ribbit's features and publically accessible by all.

Light/Dark Mode Toggle

Light/dark mode toggle

I know firsthand how important it is for websites to offer a light/dark mode toggle, and made sure that Ribbit was no different. This toggle can be found in the righthand dropdown menu in the navbar. The user's preference persists beyond refresh for a better user experience.

Responsive Design

Ribbit comes equipped with a fully responsive design, allowing users of most devices and screen sizes to use Ribbit with ease. This includes a custom design for mobile screen sizes to prevent a terrible UI from affecting the user's experience.

User Accounts & Profiles

User profile

Each user has their own profile page, where their post history can be found as well as user info like their follower count, karma, and join date. Profiles also contain ways to interact with and contact the user, including sending them a message and initiating a live chat.

When a user visits their own profile, they have access to additional information, such as the users that follow them and a list of their owned communities.

Communities

Community page

Ribbit's communities are the equivalent of Reddit's subreddits. Users are free to create communities of their own. Each community has its own page where users can find the community's posts, rules, and other information.

Subscriptions

Users are able to subscribe to communities of interest. By doing so, the community's posts are automatically included in the user's homepage feed. The community is also added to the user's list of subscriptions for easy navigation and quick post creation:

Subscriptions

Community Rules

Community rules

Community owners have the power to set the rules for their community. Community rules are typically there to help guide users on what is - and isn't - allowed when participating in discussions and creating posts.

Community Moderation

Community moderation

Ribbit allows community owners to manage their community's content by giving them the power to delete posts and comments that break their community's rules.

Community Style Settings

Community style settings

Community owners have the ability to customize the appearance of their community, which helps in giving the community its own identity. Among other settings, owners are able to change their community's theme colors, banner, and background.

Posts

There are 3 kinds of posts that users can create:

1. Text Posts

Text post

Comes with a WYSIWYG editor for easy formatting.

2. Image Posts

Image post

Upload an image from your device to share with others in the community.

Link post

The best way to share links relevant to a community's topic.

Post Feeds

There are a few different types of post feeds on Ribbit:

  1. Homepage feed: Contains posts from subscribed communities and followed users.
  2. 'All' feed: Browse all posts from all communities across Ribbit.
  3. User feed: Found on a user's profile page, and contains all of that user's posts.
  4. Community feed: Found on a community's page, and contains all of that community's posts.
Post feed

Every post feed offers sorting options ('New' and 'Top') as well as formatting options, ranging from 'Card' (the default) to 'Compact'.

Recently Viewed Posts

Recently viewed posts

Users can find a list of up to 5 of their most recently viewed posts on the homepage feed and the 'All' feed, serving as a brief look at their browsing history.

Comments

Comments

Comment sections are located on the post's page, beneath the post itself. Each comments section has the following features:

  • Comments are able to be collapsed
  • Comments with -5 points or less are automatically collapsed
  • Deleted comments are automatically collapsed
  • When a comment is deleted, rather than disappearing from the comment thread, it is replaced with a 'Deleted' placeholder to protect the integrity of the parent and children comments.
  • Users can sort comment sections in a handful of ways, including by New, Top, Best, and Controversial.
  • Community owners are clearly labelled with a 'MOD' label, and the post's author is labelled with 'OP'.
Comment Search

Comment sections include a mini search feature, allowing users to search for specific queries within those comments.

Live Chat

Live Chat

Real-time communication is available via the live chat feature, which allows users to chat directly with other users, one-on-one.

Chat Notifications

The live chat feature helpfully notifies users when they have unread chat threads by providing the number of unread threads in real time.

GIFs & Emojis

Emojis

The chat feature comes equipped with an entire library of GIFs and frog-themed emojis, allowing users to express themselves with playful visual cues.

Reactions

Users also have access to frog-themed reactions, which are little animated images that help users share a "reaction" to a message.

Notifications

Users receive notifications (in real time) for various events, such as receiving a new follower or getting a reply on a comment. Notifications remain 'unread' until clicked, and once marked read, can be marked unread again for a delayed reminder.

Voting System (Posts & Comments)

Votes

Users can express their opinions by voting on posts and comments with either an upvote, which generally implies a positive reaction or agreement, or a downvote, which generally implies a negative reaction or disagreement. The more points a post or comment has, the more likely it is to be seen by users, and the better received it tends to be.

Karma

A user's total post and comment score (upvotes minus downvotes across all posts and comments) appears as the user's "karma". When a user has a higher karma score, they tend to give off a perception that they have been around Ribbit for a while, and contribute likeable content of decent quality.

Search quick results

Located in the navbar at the top of every page is Ribbit's searchbar, which enables users to find specific or relevant posts, comments, communities, and users.

Typing a search query into the searchbar brings up the quick results menu. Full search results can be viewed either by pressing the Enter key or by clicking the Search for "query" button at the bottom of the quick results menu.

Messages

In addition to the live chat feature, users may send each other static messages via the Messages feature. All users have access to their messages inbox where they can view past and unread messages.

Users can use this feature to directly contact the owner of a community without having to know their username. This is achieved by typing c/communityName into the 'Recipient' box, replacing communityName with the community's real name.

Followers

Users may follow other users that they enjoy or find interesting. In doing so, the followed user's posts will appear in the following user's homepage feed. In addition, the followed user is added to the following user's "Followers" list for easy navigation.

Followers

Users can view a list of their followers by going to their own profile page and clicking on the 'Followers' statistic.

Favorites

Favorites

Subscribed communities and followed users can be marked as a "favorite" by interacting with the star next to their name. This adds the community/user to a special "Favorites" list, located at the top of the left nav menu, for easy navigation.