Adding Metrics to a Next.js Blog Post using Planetscale and Prisma
Dec 9th, 2022
•
•
•
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
- 5 GB of storage
- 1 billion row reads per month
- 10 million row writes per month
- 1 production branch
- 1 development branch
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.
#
#
# ...
.env
Then, open up .env
and add the connection string to your development branch
DATABASE_URL="<your connection string>"
Now, open up prisma/schema.prisma
and change the database provider to mysql
.
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
.
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.
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
.
// .
// .
// ...
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.
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.
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.
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.
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:
- Get the number of post views
- Update the number of post views
First, we want to define the return typing from the API.
export type Views = {
total: number;
};
Then, we can use the Nodejs fetch
to get the views from the database through our API.
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
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.
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.
// ...
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.
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.
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
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.
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.
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.
export type Likes = {
total?: number;
user?: boolean;
};
Then we will define the method that preforms the GET
for us.
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.
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.
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.
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.
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.
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">·</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.
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.
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