2024-08-28 Web Development

Implementing a Like Button in an MDX App with Next.js and Vercel PostgreSQL

By O Wolfson

In this article, we will walk you through the process of creating a like button for an MDX-based Next.js application. Our goal is to support both authenticated and anonymous users by leveraging local storage and a PostgreSQL database hosted on Vercel. This ensures a seamless user experience while maintaining data persistence.

See an example here.

Find the code for this project on GitHub.

Context

We are using Next.js 14 which supports Server Actions. Server Actions allow us to perform server-side operations directly from our React components, enabling smooth interactions between the client and server without needing API routes.

In this project, we are also using the @next/mdx package, which allows us to seamlessly integrate MDX content within our Next.js application. MDX combines the simplicity of Markdown with the power of React components, making it ideal for content-driven sites that require dynamic interactivity.

For our like button, we use local storage to keep track of likes for anonymous users and a PostgreSQL database hosted on Vercel for storing likes persistently across sessions and devices. This combination of technologies enables us to offer a robust user experience, whether the user is logged in or browsing anonymously.

Prerequisites

Before we start, ensure you have the following:

  • A Next.js project setup
  • Vercel PostgreSQL database credentials
  • Necessary npm packages installed (react-icons and uuid)

Step 1: Set Up Environment Variables

Create a .env.local file in the root of your project and add your Vercel PostgreSQL database credentials:

bash
POSTGRES_URL="************"
POSTGRES_PRISMA_URL="************"
POSTGRES_URL_NO_SSL="************"
POSTGRES_URL_NON_POOLING="************"
POSTGRES_USER="************"
POSTGRES_HOST="************"
POSTGRES_PASSWORD="************"
POSTGRES_DATABASE="************"

Step 2: Create the LikeButton Component

Create a new file components/like-button.tsx and add the following code:

typescript
"use client";
import React, { useState, useEffect } from "react";
import { v4 as uuidv4 } from "uuid";
import { AiOutlineLike, AiFillLike } from "react-icons/ai";
import {
  createTable,
  removeTable,
  addLike,
  removeLike,
  removeAllLikes,
  countLikes,
} from "@/app/actions/like-actions";

interface LikeButtonProps {
  postId: number;
}

function LikeButton({ postId }: LikeButtonProps) {
  const [liked, setLiked] = useState(false);
  const [userId, setUserId] = useState<string | null>(null);
  const [totalLikes, setTotalLikes] = useState<number>(0);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Check for an existing user ID in local storage or create a new one
    let storedUserId = localStorage.getItem("userIdForMDXApp");
    if (!storedUserId) {
      storedUserId = uuidv4();
      localStorage.setItem("userIdForMDXApp", storedUserId);
    }
    setUserId(storedUserId);

    // Check if the post is already liked
    const likedPosts = JSON.parse(localStorage.getItem("likedPosts") || "{}");
    if (likedPosts[postId]) {
      setLiked(true);
    }

    // Fetch total likes
    fetchTotalLikes();

    // Set loading to false after data is loaded
    setLoading(false);
  }, [postId]);

  async function fetchTotalLikes() {
    console.log("Fetching total likes for post", postId);
    const response = await countLikes(postId);
    if (response.success && typeof response.count === "number") {
      setTotalLikes(response.count);
      console.log("Total likes fetched successfully:", response.count);
    } else {
      console.error("Failed to fetch total likes:", response.error);
      setTotalLikes(0); // default to 0 if there's an error or count is not a number
    }
  }

  async function handleLikeAction() {
    if (userId) {
      console.log("Liking post", postId, "with user ID", userId);
      const response = await addLike(postId, userId);
      if (response.success) {
        const likedPosts = JSON.parse(
          localStorage.getItem("likedPosts") || "{}"
        );
        likedPosts[postId] = true;
        localStorage.setItem("likedPosts", JSON.stringify(likedPosts));
        setLiked(true);
        setTotalLikes((prev) => prev + 1);
        console.log("Like added successfully");
      } else {
        console.error("Failed to add like:", response.error);
      }
    }
  }

  async function handleUnlikeAction() {
    if (userId) {
      console.log("Unliking post", postId, "with user ID", userId);
      const response = await removeLike(postId, userId);
      if (response.success) {
        const likedPosts = JSON.parse(
          localStorage.getItem("likedPosts") || "{}"
        );
        delete likedPosts[postId];
        localStorage.setItem("likedPosts", JSON.stringify(likedPosts));
        setLiked(false);
        setTotalLikes((prev) => prev - 1);
        console.log("Like removed successfully");
      } else {
        console.error("Failed to remove like:", response.error);
      }
    }
  }

  async function handleRemoveAllLikes() {
    console.log("Removing all likes");
    const response = await removeAllLikes();
    if (response.success) {
      localStorage.setItem("likedPosts", "{}");
      setLiked(false);
      setTotalLikes(0);
      console.log("All likes removed successfully");
    } else {
      console.error("Failed to remove all likes:", response.error);
    }
  }

  async function handleCreateTable() {
    console.log("Creating table");
    const response = await createTable();
    if (response.success) {
      console.log("Table created successfully");
    } else {
      console.error("Failed to create table:", response.error);
    }
  }

  async function handleRemoveTable() {
    console.log("Removing table");
    const response = await removeTable();
    if (response.success) {
      console.log("Table removed successfully");
    } else {
      console.error("Failed to remove table:", response.error);
    }
  }

  if (loading) {
    return null; // or you can return a loading spinner
  }

  return (
    <div className="py-8">
      <div>Total Likes: {totalLikes}</div>
      <button
        type="button"
        onClick={() => {
          liked ? handleUnlikeAction() : handleLikeAction();
        }}
        className={
          "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded flex items-center"
        }
      >
        {liked ? (
          <AiFillLike className="text-xl mr-2" />
        ) : (
          <AiOutlineLike className="text-xl mr-2" />
        )}
        {liked ? "Liked" : "Like"}
      </button>
      {/* the buttons below are hidden, un-hide to enable admin features */}
      <div className="hidden">
        <button
          type="button"
          onClick={handleCreateTable}
          className={
            "bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded mt-2"
          }
        >
          Create Table
        </button>
        <button
          type="button"
          onClick={handleRemoveTable}
          className={
            "bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded mt-2"
          }
        >
          Remove Table
        </button>
        <button
          type="button"
          onClick={handleRemoveAllLikes}
          className={
            "bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded mt-2"
          }
        >
          Remove All Likes
        </button>
      </div>
    </div>
  );
}

export default LikeButton;

Explanation:

  • State Management: We use React's useState and useEffect hooks to manage the component's state, including whether the post is liked and the total number of likes.
  • Local Storage: For anonymous users, we store the user's ID and the liked posts in local storage using the key userIdForMDXApp. This ensures that the user's likes persist across sessions on the same device.
  • Icons: The react-icons package provides the like icons, which toggle between an outlined and filled icon based on the user's action.

Step 3: Create Server Actions for Database Interaction

Create a new file app/actions/like-actions.ts and add the following code:

typescript
"use server";
import { sql } from "@vercel/postgres";

// Function to count the likes for a specific post
export async function countLikes(postId: number) {
  console.log("countLikes", postId);
  try {
    const result = await sql`
      SELECT COUNT(*) as count FROM likes_for_test
      WHERE postid = ${postId};
    `;
    console.log("count

Likes result:", result);
    if (result.rows && result.rows.length > 0) {
      return { success: true, count: Number.parseInt(result.rows[0].count, 10) };
    } else {
      return { success: false, error: "No results found" };
    }
  } catch (error) {
    console.error("Error in countLikes:", (error as Error).message);
    return { success: false, error: (error as Error).message };
  }
}

// Function to create the likes table in the database
export async function createTable() {
  console.log("createTable");
  try {
    const result = await sql`
      CREATE TABLE IF NOT EXISTS likes_for_test (
        postid INT,
        userid VARCHAR(255)
      );
    `;
    return { success: true, result };
  } catch (error) {
    console.error("Error in createTable:", (error as Error).message);
    return { success: false, error: (error as Error).message };
  }
}

// Function to remove the likes table from the database
export async function removeTable() {
  console.log("removeTable");
  try {
    const result = await sql`
      DROP TABLE IF EXISTS likes_for_test;
    `;
    return { success: true, result };
  } catch (error) {
    console.error("Error in removeTable:", (error as Error).message);
    return { success: false, error: (error as Error).message };
  }
}

// Function to add a like for a specific post and user
export async function addLike(postId: number, userId: string) {
  console.log("addLike", postId, userId);
  try {
    if (!postId || !userId) {
      throw new Error("Missing postId or userId");
    }

    const result = await sql`
      INSERT INTO likes_for_test (postid, userid)
      VALUES (${postId}, ${userId});
    `;

    return { success: true, result };
  } catch (error) {
    console.error("Error in addLike:", (error as Error).message);
    return { success: false, error: (error as Error).message };
  }
}

// Function to remove a like for a specific post and user
export async function removeLike(postId: number, userId: string) {
  console.log("removeLike", postId, userId);
  try {
    if (!postId || !userId) {
      throw new Error("Missing postId or userId");
    }

    const result = await sql`
      DELETE FROM likes_for_test
      WHERE postid = ${postId} AND userid = ${userId};
    `;

    return { success: true, result };
  } catch (error) {
    console.error("Error in removeLike:", (error as Error).message);
    return { success: false, error: (error as Error).message };
  }
}

// Function to remove all likes from the database
export async function removeAllLikes() {
  console.log("removeAllLikes");
  try {
    const result = await sql`
      DELETE FROM likes_for_test;
    `;
    return { success: true, result };
  } catch (error) {
    console.error("Error in removeAllLikes:", (error as Error).message);
    return { success: false, error: (error as Error).message };
  }
}

Explanation:

  • Server Actions: In Next.js 14, Server Actions allow us to perform server-side operations directly from our React components. These actions handle database mutations, such as adding or removing likes.
  • SQL Queries: We use SQL queries to interact with the PostgreSQL database, such as creating the likes table, inserting likes, and counting them.

Step 4: Embed the LikeButton in Your MDX File

To embed the LikeButton component in your MDX content, use the following code:

mdx
import LikeButton from "../components/like-button";

export const metadata = {
  title: "Like Button Article",
  publishDate: "2024-03-01T00:00:00Z",
  id: 1,
};

Content

<LikeButton postId={metadata.id} />

Explanation:

  • MDX Integration: Here, we import the LikeButton component and embed it directly within our MDX content. This is one of the key benefits of using MDX—it allows you to include dynamic React components within your Markdown content.

Conclusion

By following these steps, you have successfully implemented a like button system that supports both anonymous and authenticated users in your Next.js MDX app. This approach leverages local storage for anonymous users and a PostgreSQL database for persistent storage, ensuring a seamless user experience across sessions and devices.

For more information, check out the official documentation on Vercel's PostgreSQL database integration.