Adding Metrics to a Next.js Blog Post using Planetscale and Prisma

Dec 9th, 2022

views

likes

3,833 words (19 minutes)

Adding metrics to a post is both a fun coding challenge as well as a nice to have. We are going to utilize a PlanetScale database, which has a generous "Hobby" plan. This plan includes

which is more than enough for our use case! We are also going to use Prisma to define all of our models.

Setting up the Database

Planetscale's documentation is pretty solid, and they have a quickstart guide. Follow the instructions for creating a database then promote the default main branch to production. Planetscale follows a git-like branching concept, which your development branch intended for applying new schema changes.

Promoting the branch will protect it from direct schema changes, make it more available, and ensure there are daily scheduled backups.

You will also want to make a new branch for development. Make sure you grab the connection strings to your main branch, and development branch, and store them somewhere safe.

Installing Prisma

Once your Planetscale database is up and running, we can go ahead and set up Prisma. Prisma is a type-safe database client, where the one of the main selling points is the "human-readable" schema files. We will start by installing Prisma.

yarn add prisma

then, we will initialize the prisma part of the project using

npx prisma init

The above command creates the /prisma directory, with a file called schema.prisma. This is where we will define our schemas. The command also creates a .env file in the project directory.

Connecting Planetscale to Prisma

Add the .env file to .gitignore. You don't want to commit this file to any source control, since it often contains sensitive information.

.gitignore
#
#
# ...
.env

Then, open up .env and add the connection string to your development branch

.env
DATABASE_URL="<your connection string>"

Now, open up prisma/schema.prisma and change the database provider to mysql.

prisma/schema.prisma
datasource db {
    provider    =   "mysql"
    url         =   env("DATABASE_URL")
}

You will also need to add the referentialIntegrity feature to the generator block, and set relationMode in the database block to prisma.

prisma/schema.prisma
datasource db {
    provider        =   "mysql"
    url             =   env("DATABASE_URL")
    relationMode    =   "prisma"
}
 
generator client {
    provider        =   "prisma-client-js"
    previewFeatures =   ["referentialIntegrity"]
}

Setting Up the Prisma client

I keep many of my helper methods in a directory called /lib. There, we are going to create a new file called prisma.ts. Our goal with this helper is to follow the best Next.js practices according to Prisma.

lib/prisma.ts
import { PrismaClient } from "@prisma/client";
 
// PrismaClient is attached to the `global` object in development
// to prevent exhausting your database connection limit.
//
// Learn more:
// https://pris.ly/d/help/next-js-best-practices
 
let prisma: PrismaClient;
 
if (process.env.NODE_ENV === "production") {
  prisma = new PrismaClient();
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient();
  }
  prisma = global.prisma;
}
 
export default prisma;

You may get an error in your IDE saying that it can't find the client. Not to worry! In the project directory run npx prisma db push. This command will push these changes to your database development branch, and generate a typesafe client.

Supporting Metrics from the Backend

Now that we got the setup out of the way, we can move on to adding the feature -- which is the post metrics! I use the term "backend" loosely here, basically just referring to defining our schema in Prisma and defining the Next.js API routes and React hooks to access the data.

Note: this next part assumes you followed the last part of this series that set up content using contentlayer

Views

First, let's begin by adding our views. Breaking this down conceptually is pretty simple... We are going to store views in the database via the slug of post (using the slug as the index on the table).

Schema

We are going to open up prisma/schema.prisma and add a table called Stats. Eventually, we can split our schemas into separate files, but for the time being they won't be too complex and can stay in the same file.

We can create a new table using the model keyword, and then add two columns, slug and views.

prisma/schema.prisma
// .
// .
// ...
 
model Stats {
    slug    String     @id @db.VarChar(128)
    views   Int        @default(0)
}

After making any schema changes, be sure to run npx prisma db push to update the database tables and regenerate the client.

API Route

Now, we can use a Next.js API Route in tandem with the global prisma instance we created to query our database for a post's number of views.

In the /pages directory, create the following structure.

└── api
    └── views
        └── [slug].ts

Open /pages/api/views/[slug].ts in your editor of choice, and create the handler function.

/pages/api/view/[slug].ts
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@/lib/prisma";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // Your API method here!
}

We want to handle two cases, the POST and the GET. We also want to make sure we have adequate error handling.

/pages/api/view/[slug].ts
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@/lib/prisma";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // Your API method here!
  try {
    // grab the slug of the article
    const slug = req.query.slug?.toString();
 
    if (req.method === "POST") {
      // handle the post
    }
 
    if (req.method === "GET") {
      // handle the get
    }
  } catch (e) {
    return res.status(500).json({ message: e.message });
  }
}

On the POST, we will use the prisma client's upsert method to handle the request. Basically, we will increment the view field on update, or create a new record with the slug.

/pages/api/view/[slug].ts
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@/lib/prisma";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // Your API method here!
  try {
    // grab the slug of the article
    const slug = req.query.slug?.toString();
 
    if (req.method === "POST") {
      const newOrUpdatedViews = await prisma.stats.upsert({
        where: { slug },
        create: {
          // @ts-ignore
          slug,
        },
        update: {
          views: {
            increment: 1,
          },
        },
      });
 
      return res.status(200).json({
        total: newOrUpdatedViews.views.toString(),
      });
    }
 
    if (req.method === "GET") {
      // handle the get
    }
  } catch (e) {
    return res.status(500).json({ message: e.message });
  }
}

The GET is simpler, we will just find the unique record associated with the slug.

/pages/api/view/[slug].ts
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@/lib/prisma";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // Your API method here!
  try {
    // grab the slug of the article
    const slug = req.query.slug?.toString();
 
    if (req.method === "POST") {
      const newOrUpdatedViews = await prisma.stats.upsert({
        where: { slug },
        create: {
          // @ts-ignore
          slug,
        },
        update: {
          views: {
            increment: 1,
          },
        },
      });
 
      return res.status(200).json({
        total: newOrUpdatedViews.views.toString(),
      });
    }
 
    if (req.method === "GET") {
      // handle the get
      const stats = await prisma.stats.findUnique({
        where: {
          slug,
        },
      });
 
      return res.status(200).json({ total: stats?.views.toString() });
    }
  } catch (e) {
    return res.status(500).json({ message: e.message });
  }
}

With the actual "API" route defined, we can go ahead and write the React Hook to use it.

Hook

Create a new file in the /lib directory called usePostViews.ts. We have two goals for the hook:

  1. Get the number of post views
  2. Update the number of post views

First, we want to define the return typing from the API.

/lib/usePostViews.ts
export type Views = {
  total: number;
};

Then, we can use the Nodejs fetch to get the views from the database through our API.

/lib/usePostViews.ts
export type Views = {
  total: number;
};
 
const API_URL = "/api/views";
 
async function getPostViews(slug: string): Promise<Views> {
  const res = await fetch(`${API_URL}/${slug}`);
  if (!res.ok) {
    throw new Error("An error occured while fetching views");
  }
  return res.json();
}

After that, we can also handle the update method

/lib/usePostViews.ts
export type Views = {
  total: number;
};
 
const API_URL = "/api/views";
 
async function getPostViews(slug: string): Promise<Views> {
  const res = await fetch(`${API_URL}/${slug}`);
  if (!res.ok) {
    throw new Error("An error occured while fetching views");
  }
  return res.json();
}
 
async function updatePostViews(slug: string): Promise<Views> {
  const res = await fetch(`${API_URL}/${slug}`, { method: "POST" });
  if (!res.ok) {
    throw new Error("An error occured while updating views");
  }
  return res.json();
}

And finally, we can use SWR, a tool built by the people over at Vercel, to then define our usePostViews hook.

/lib/usePostViews.ts
import useSWR, { SWRConfiguration } from "swr";
 
export type Views = {
	total: number
};
 
const API_URL = '/api/views';
 
async function getPostViews(slug: string): Promise<Views> {
	const res = await fetch(`${API_URL}/${slug}`);
	if (!res.ok) {
		throw new Error("An error occured while fetching views");
	}
	return res.json();
}
 
async function updatePostViews(slug: string): Promise<Views> {
	const res = await fetch(`${API_URL}/${slug}`, { method: "POST" });
	if (!res.ok) {
		throw new Error("An error occured while updating views");
	}
	return res.json();
}
 
export const usePostViews = (slug: string, config?: SWRConfiguration) => {
	const { data, error, mutate } = useSWR<Views>(
		`${API_URL}/${slug}`,
		() => getPostViews(slug),
		{
			dedupingInterval: 600000,
			...config
		}
	)
 
	const views = data?.total;
 
	const increment = () => {
		mutate(
			updatePostViews(slug).catch(e => {
				console.log(e);
				return { total: 0 };
			});
		)
	};
 
	return {
		views,
		isLoading: !error && !views,
		isError: !!error,
		increment
	};
}

Likes

Adding "likes" to our posts is a pretty similar concept, plus a tiny bit of logic. When you're talking about likes, you want users of your site to have the articles they liked to be remembered the next time they visit. So essentially, we don't want the likes to be wiped the next time a user visits the site.

So how do we fix that?

Schema

Well, we represent the concept of a Session within our database. We will utilize the user's IP address (hashed for privacy) along with the slug of the article to generate a ID for the row in the database, and each row in the database will represent a like.

/prisma/schema.prisma
// ...
 
model Stats {
	slug      String  @id @db.VarChar(128)
	likes     Int     @default(0)
	views     Int     @default(0)
}
 
model Session {
	id        String   @id
	createdAt DateTime @default(now())
}

(Don't forget to run npx prisma db push)

API Route

Create a folder inside of /pages/api called likes, and then create a file called [slug].ts.

└── api
    └── likes
        └── [slug].ts

Here, we will handle the GET, POST, and DELETE. We will start out by using crypto to hash the user's IP address.

yarn add crypto

Be sure to add a IP_ADDRESS_SALT to your .env file, and then you can use the createHash method to hash the user's IP address.

/pages/api/likes/[slug].ts
import prisma from "@/lib/prisma";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const ip = req.headers["x-forwarded-for"] || "0.0.0";
  const slug = req.query.slug?.toString();
 
  try {
    const userId = createHash("md5")
      .update(
        // @ts-ignore
        ip + process.env.IP_ADDRESS_SALT,
        "utf-8"
      )
      .digest("hex");
  } catch (e: any) {
    res.status(500).json({
      message: e.message,
    });
  }
}

We can use this userId to create an ID for our session.

/pages/api/likes/[slug].ts
import prisma from "@/lib/prisma";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const ip = req.headers["x-forwarded-for"] || "0.0.0";
  const slug = req.query.slug?.toString();
 
  try {
    const userId = createHash("md5")
      .update(
        // @ts-ignore
        ip + process.env.IP_ADDRESS_SALT,
        "utf-8"
      )
      .digest("hex");
 
    const session = `${slug}__${userId}`;
  } catch (e: any) {
    res.status(500).json({
      message: e.message,
    });
  }
}

To handle the GET, we will need to return whether the current user (IP address) has previously liked this article, and the number of likes for the article. We can accomplish this using a Promise.all

/pages/api/likes/[slug].ts
import prisma from "@/lib/prisma";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const ip = req.headers["x-forwarded-for"] || "0.0.0";
  const slug = req.query.slug?.toString();
 
  try {
    const userId = createHash("md5")
      .update(
        // @ts-ignore
        ip + process.env.IP_ADDRESS_SALT,
        "utf-8"
      )
      .digest("hex");
 
    const session = `${slug}__${userId}`;
 
    if (req.method === "GET") {
      const [stats, userHasLiked] = await Promise.all([
        prisma.stats.findUnique({
          where: { slug },
        }),
        prisma.session.findUnique({
          where: { id: session },
        }),
      ]);
 
      res.status(200).json({
        total: stats?.likes,
        user: userHasLiked != null,
      });
    }
  } catch (e: any) {
    res.status(500).json({
      message: e.message,
    });
  }
}

Then, to handle the POST, we will need to determine whether there exists a row in the Session table that matches the user's IP. If there is a row, there is no change to the number of likes. Otherwise, we create a new row in the Session table and increment the number of likes in the Stats table.

/pages/api/likes/[slug].ts
import prisma from "@/lib/prisma";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const ip = req.headers["x-forwarded-for"] || "0.0.0";
  const slug = req.query.slug?.toString();
 
  try {
    const userId = createHash("md5")
      .update(
        // @ts-ignore
        ip + process.env.IP_ADDRESS_SALT,
        "utf-8"
      )
      .digest("hex");
 
    const session = `${slug}__${userId}`;
 
    if (req.method === "GET") {
      const [stats, userHasLiked] = await Promise.all([
        prisma.stats.findUnique({
          where: { slug },
        }),
        prisma.session.findUnique({
          where: { id: session },
        }),
      ]);
 
      res.status(200).json({
        total: stats?.likes,
        user: userHasLiked != null,
      });
    }
 
    if (req.method === "POST") {
      try {
        await prisma.session.findUniqueOrThrow({
          where: { id: session },
        });
      } catch (e) {
        await prisma.session.create({
          data: {
            id: session,
          },
        });
      }
 
      const newOrUpdatedLikes = await prisma.stats.upsert({
        where: { slug },
        create: {
          // @ts-ignore
          slug,
        },
        update: {
          likes: {
            increment: 1,
          },
        },
      });
 
      res.status(200).json({
        total: newOrUpdatedLikes.likes.toString(),
        user: true,
      });
    }
  } catch (e: any) {
    res.status(500).json({
      message: e.message,
    });
  }
}

Finally, we want to allow user's to un-like the post (sadly), so we want to handle the DELETE. Here, we will remove the user's IP from the Session table, and decrement the likes column in the Stats table.

/pages/api/likes/[slug].ts
import prisma from "@/lib/prisma";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const ip = req.headers["x-forwarded-for"] || "0.0.0";
  const slug = req.query.slug?.toString();
 
  try {
    const userId = createHash("md5")
      .update(
        // @ts-ignore
        ip + process.env.IP_ADDRESS_SALT,
        "utf-8"
      )
      .digest("hex");
 
    const session = slug + "___" + userId;
 
    if (req.method === "GET") {
      const [stats, userHasLiked] = await Promise.all([
        prisma.stats.findUnique({
          where: { slug },
        }),
        prisma.session.findUnique({
          where: { id: session },
        }),
      ]);
 
      res.status(200).json({
        total: stats?.likes,
        user: userHasLiked != null,
      });
    }
 
    if (req.method === "POST") {
      try {
        await prisma.session.findUniqueOrThrow({
          where: { id: session },
        });
      } catch (e) {
        await prisma.session.create({
          data: {
            id: session,
          },
        });
      }
 
      const newOrUpdatedLikes = await prisma.stats.upsert({
        where: { slug },
        create: {
          // @ts-ignore
          slug,
        },
        update: {
          likes: {
            increment: 1,
          },
        },
      });
 
      res.status(200).json({
        total: newOrUpdatedLikes.likes.toString(),
        user: true,
      });
    }
 
    if (req.method === "DELETE") {
      await prisma.session.delete({
        where: {
          id: session,
        },
      });
 
      const stats = await prisma.stats.update({
        where: {
          slug,
        },
        data: {
          likes: {
            decrement: 1,
          },
        },
      });
 
      res.status(200).json({
	      total: stats.likes.toString(),
	      user: false
      });
    }
  } catch (e: any) {
    res.status(500).json({
      message: e.message,
    });
  }

Hook

Similar to the hook for an article's views, we will start by defining the return type for the API call.

/lib/usePostLikes.ts
export type Likes = {
  total?: number;
  user?: boolean;
};

Then we will define the method that preforms the GET for us.

/lib/usePostLikes.ts
export type Likes = {
  total?: number;
  user?: boolean;
};
 
const API_URL = `/api/likes`;
 
async function getPostLikes(slug: string): Promise<Likes> {
  const res = await fetch(`${API_URL}/${slug}`);
  if (!res.ok) {
    throw new Error("An error occurred while fetching the data.");
  }
  return res.json();
}

After that, we can handle the POST calls and DELETE calls.

/lib/usePostLikes.ts
import React from "react";
import useSWR, { SWRConfiguration } from "swr";
 
export type Likes = {
  total?: number;
  user?: boolean;
};
 
const API_URL = `/api/likes`;
 
async function getPostLikes(slug: string): Promise<Likes> {
  const res = await fetch(`${API_URL}/${slug}`);
  if (!res.ok) {
    throw new Error("An error occurred while fetching the data.");
  }
  return res.json();
}
 
async function updatePostLikes(slug: string): Promise<Likes> {
  const res = await fetch(`${API_URL}/${slug}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
  });
  if (!res.ok) {
    throw new Error("An error occurred while posting the data.");
  }
  return res.json();
}
 
async function deletePostLikes(slug): Promise<Likes> {
  const res = await fetch(`${API_URL}/${slug}`, {
    method: "DELETE",
    headers: { "Content-Type": "application/json" },
  });
  if (!res.ok) {
    throw new Error("An error occurred while posting the data.");
  }
  return res.json();
}

Once we have those methods defined, we can create the actual hook to be used inside our component.

/lib/usePostLikes.ts
import React from "react";
import useSWR, { SWRConfiguration } from "swr";
 
export type Likes = {
  total?: number;
  user?: boolean;
};
 
const API_URL = `/api/likes`;
 
async function getPostLikes(slug: string): Promise<Likes> {
  const res = await fetch(`${API_URL}/${slug}`);
  if (!res.ok) {
    throw new Error("An error occurred while fetching the data.");
  }
  return res.json();
}
 
async function updatePostLikes(slug: string): Promise<Likes> {
  const res = await fetch(`${API_URL}/${slug}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
  });
  if (!res.ok) {
    throw new Error("An error occurred while posting the data.");
  }
  return res.json();
}
 
async function deletePostLikes(slug): Promise<Likes> {
  const res = await fetch(`${API_URL}/${slug}`, {
    method: "DELETE",
    headers: { "Content-Type": "application/json" },
  });
  if (!res.ok) {
    throw new Error("An error occurred while posting the data.");
  }
  return res.json();
}
 
export const usePostLikes = (slug: string, config?: SWRConfiguration) => {
  const { data, error, mutate } = useSWR(
    [API_URL, slug],
    () => getPostLikes(slug),
    {
      dedupingInterval: 60000,
      ...config,
    }
  );
 
  const [liked, setLiked] = React.useState(data?.user);
 
  const increment = () => {
    // do nothing if the user has already liked
    if (!data || data.user) {
      return;
    }
 
    mutate(
      {
        total: data.total + 1,
        user: true,
      },
      false
    );
 
    setLiked(true);
    updatePostLikes(slug);
  };
 
  const decrement = () => {
    if (!data || !data.user) {
      return;
    }
    mutate({ total: data.total - 1, user: false }, false);
 
    setLiked(false);
    deletePostLikes(slug);
  };
 
  return {
    user: data?.user,
    likes: data?.total,
    isLoading: !error && !data,
    isError: !!error,
    increment,
    decrement,
  };
};

Displaying the Metrics

The "complicated" part is now out of the way, so we can move on to displaying our metrics. I will start with a simple <span> component that simply displays the numerical value.

/ui/metric.tsx
export const Metric = ({ stat }: { stat: number | undefined }) => {
  return (
    <span
      className="-mx-0.5 animate-[mutation_2s_ease-in-out_1] rounded-md px-0.5 slashed-zero tabular-nums tracking-tight"
      Ï
    >
      {stat?.toLocaleString()}
    </span>
  );
};

We can use that component inside a reusable PostMetrics component that displays both views and likes of a given article.

Before we can do that, we need to introduce the concept of polling into our application. We have data that could (theroretically) be changing any given second, we don't want to use our applications resources to constantly be updating this information. We want to poll this data in intervals.

Create a new file in /lib called usePolling.ts. In this file, we will define a hook to poll on an interval.

/lib/usePolling.ts
import React from "react";
import { useIntersection, useInterval } from "react-use";
 
export const usePolling = (interval: number = 1000, max: number = 3) => {
  const intersectionRef = React.useRef(null);
  const intersection = useIntersection(intersectionRef, {});
 
  const [shouldPoll, setShouldPoll] = React.useState(false);
  const [polledCount, setPolledCount] = React.useState(0);
 
  useInterval(
    () => {
      if (polledCount >= max) {
        setShouldPoll(false);
      } else {
        setPolledCount(polledCount + 1);
      }
    },
    shouldPoll ? interval : null
  );
 
  React.useEffect(() => {
    if (intersection?.isIntersecting) {
      setShouldPoll(true);
      setPolledCount(0);
    } else {
      setShouldPoll(false);
    }
  }, [intersection?.isIntersecting]);
 
  return {
    intersectionRef,
    shouldRef,
  };
};

We can then use this hook to determine when we should poll for new data within the article metrics display.

/ui/post-metrics.tsx
import { usePolling } from "@/lib/usePolling";
import { usePostViews } from "@/lib/usePostViews";
 
import { LoadingDots } from "./loading";
import { Metric } from "./metric";
 
export const PostMetrics = ({ slug }: { slug: string }) => {
	const interval = 5000;
	const { shouldPoll, intersectionRef } = usePolling(interval);
 
	const {
		views,
		isLoading: viewsIsLoading.
		isError: viewsIsError
	} = usePostViews(slug, {
		revalidateOnMount: false,
		refreshInterval: shouldPoll ? interval : 0,
		dedupingInterval: interval
	});
 
	const {
		likes,
		isLoading: likesIsLoading,
		isError: likesIsError
	} = usePostLikes(slug, {
		refreshInterval: shouldPoll ? interval : 0
	});
 
	return (
		<div ref={intersectionRef}>
			<div>
				{ viewsIsError || viewsIsLoading ? (
					<LoadingDots />
				) : (
					<Metric key={views} stat={views} />
				)}
			</div>
			<div className="text-white-100/30">&middot;</div>
			<div>
				{ likeIsError || likesIsLoading ? (
					<LoadingDots />
				): (
					<Metric key={likes} stat={likes} />
				)}
			</div>
		</div>
	)
};

Updating Metrics within the App

If you look back at the usePostViews and usePostLikes, we export an increment and decrement member (respectively).

We can use the increment from usePostViews to increment the number of views using the useEffect hook

🌟 useEffect can be thought of as componentDidMount, componentDidUpdate, and componentWillUnmount combined.

/ui/view-counter.tsx
import { usePolling } from "@/lib/usePolling";
import { usePostViews } from "@/lib/usePostViews";
 
import { LoadingDots } from "./loading";
import { Metric } from "./metric";
 
export default function ViewCounter = ({ slug } : { slug: string }) => {
	const interval = 5000;
	const { shouldPoll, intersectionRef } = usePolling(interval);
 
	const {
		views,
		isLoading,
		isError,
		increment
	} = usePostViews(slug, {
		revalidateOnMount: false,
		refreshInterval: shouldPoll ? interval : 0,
		dedupingInterval: interval
	});
 
	useEffect(() => {
		increment();
	}, []);
 
	return (
		<div ref={intersectionRef}>
			{ isError || isLoading ? (
				<LoadingDots />
			): (
				<Metric key={views} stat={views} />
			)}
		</div>
	)
}

On the flip side, you can also use increment and decrement within a button's onClick member.

/ui/likes-counter
import { usePolling } from "@/lib/usePolling";
import { usePostLikes } from "@/lib/usePostLikes";
import { HandThumbUpIcon } from "@heroicons/react/24/outline";
import { LoadingDots } from "./loading";
import { Metric } from "./metric";
 
export default const LikeCounter = ({ slug } : { slug: string }) => {
	const interval = 5000;
	const { shouldPoll, intersectionRef } = usePolling(interval);
 
	const { user, likes, isError, isLoading, increment, decrement } =
		usePostLikes(slug, {
			refreshInterval: shouldPoll ? interval : 0
		});
 
	return (
		<div className="flex flex-row space-x-2">
			<div ref={intersectionRef}>
				{ isError || isLoading ? (
					<LoadingDots />
				) : (
					<Metric stat={likes} />
				)} likes
			</div>
			<button
				className={`hover:text-white/60 ${user ? "text-blue-300": ""}`}
				onClick={() => {
					if (isLoading) { return };
					if (user) {
						decrement();
					} else {
						increment();
					}
				}}
			>
				<HandsThumbsUpIcon className="w-5" />
			</button>
		</div>
	)
}

Next.js

Planetscale

Prisma