Inspired by many blogs that show a nice-looking view counter on every page, I also wanted to develop such a feature for my blog. I’m also using Next.js 14 with the new app router, but instead of storing the page views in a relational database like PostgreSQL, I’ll be using Upstash Redis.
Here is the GitHub repo with the full code.
Example page hosted on Vercel -> https://nextjs14-azureb2c-prisma.vercel.app/
Why Redis and not a Relational DB like PostgreSQL?
Redis offers great commands that make it easy to deduplicate and increment a counter, which is crucial for accuracy.
I want to debounce the counter incrementing to get a more accurate counter. If a user refreshes the page, the counter should only be incremented once. We can do this easily with Redis’ SET command. It has a NX option that will only set the key if it doesn't exist yet and an EX option that will expire the key after a given amount of seconds. By combining both options, we can ensure a single user does not increment the counter multiple times within a given timeframe.
The second command is INCR, incrementing a given key by 1 atomically. And last but not least, we can offload this task from our “default” relational DB.
Next.js Api Route for Redis Operations
We create the file src/app/api/viewcount, where we import our Redis Lib file, which we created in the previous post.
// lib/redis.ts
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export default redis;Upstash Redis NPM Package
The same applies to the needed Upstash Redis NPM Package we installed during the previous story.
npm i @upstash/redisVercel EDGE
Upstash and @upstash/redis are compatible with Vercel's edge functions, so first, we will import everything we need, set up Redis, and configure the runtime to be edge.
//src/app/api/viewcount
import { NextRequest, NextResponse } from "next/server";
import redis from "../../../lib/redis";
export const runtime = "edge";
export async function POST(req: NextRequest) {
// export default async function incr(req: NextRequest): Promise<NextResponse> {
const body = await req.json();
const slug = body.slug as string | undefined;
if (!slug) {
return new NextResponse("Slug not found", { status: 400 });
}
const { ip } = req;
// Hash the IP and turn it into a hex string
const buf = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(ip)
);
const hash = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const isNew = await redis.set(["deduplicate", hash, slug].join(":"), true, {
nx: true,
ex: 24 * 60 * 60,
});
if (!isNew) {
new NextResponse(null, { status: 202 });
}
await redis.incr(["pageviews", "example", slug].join(":"));
return new NextResponse(null, { status: 202 });
}New Tracking Component “viewcount.tsx”
We pass the slug attribute to the earlier created API route with “UseEffect”.
//src/component/analytics/viewcount.tsx
"use client";
import { useEffect } from "react";
import { NextRequest, NextResponse } from "next/server";
interface ViewCountProps {
slug: string;
}
export const ReportView = ({ slug }: ViewCountProps) => {
useEffect(() => {
fetch("/api/viewcount", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ slug }),
});
}, [slug]);
return null;
};Project Integration “page.tsx”
As a last step, we must integrate the new Component into our Next.js 14 Project.
Import Redis Lib and viewcount component
// ViewCount
import { ReportView } from "@/components/analytics/viewcount";
import redis from "../../../lib/redis";Defining const Views on line 222
const views =
(await redis.get<number>(
[
"pageviews",
"example",
params.locale.toString() + "/" + blogPost.slug,
].join(":")
)) ?? 0;Using imported component on line 243
<ReportView slug={params.locale.toString() + "/" + blogPost.slug || ""} />Showing views on the bottom (line 282)
<div className="text-base p-2 dark:bg-gray-500 rounded-lg dark:text-white bg-gray-200 text-gray-600 w-24 mt-4">
Views: {views}
</div>Below is the full code (page.tsx)
//src/app/[locale]/[slug]/page.tsx
import { ArticleContent } from "@/components/contentful/ArticleContent.component";
import { client } from "@/lib/client";
import { notFound } from "next/navigation";
import { ArticleHero } from "@/components/contentful/ArticleHero";
import { ArticleTileGrid } from "@/components/contentful/ArticleTileGrid";
import { Container } from "@/components/contentful/container/Container";
import { draftMode } from "next/headers";
// Internationalization
import { createTranslation } from "@/app/i18n/server";
import { locales, LocaleTypes } from "@/app/i18n/settings";
//SEO - JSON-LD
import { Article, WithContext } from "schema-dts";
import path from "path";
import Script from "next/script";
import { Metadata, ResolvingMetadata } from "next";
import { TagCloudSimpleHome } from "@/components/search/tagcloudsimpleHome.component";
// Claps
import ClapButton from "@/components/contentful/ClapButton.component";
// ViewCount
import { ReportView } from "@/components/analytics/viewcount";
import redis from "../../../lib/redis";
interface BlogPostPageParams {
slug: string;
locale: string;
}
interface BlogPostPageProps {
params: BlogPostPageParams;
}
type ViewCountProps = {
params: {
slug: string;
};
};
// Tell Next.js about all our blog posts so
// they can be statically generated at build time.
export async function generateStaticParams(): Promise<BlogPostPageParams[]> {
const dataPerLocale = locales
? await Promise.all(
locales.map((locale) => client.pageBlogPostCollection({ limit: 100 }))
)
: [];
// If const dataPerLocale is empty, return an empty array
if (!dataPerLocale) {
return notFound();
}
const paths = dataPerLocale
.flatMap((data, index) =>
data.pageBlogPostCollection?.items.map((blogPost) =>
blogPost?.slug
? {
slug: blogPost.slug,
locale: locales?.[index] || "",
}
: undefined
)
)
.filter(Boolean);
return paths as BlogPostPageParams[];
}
const generateUrl = (locale: string, slug: string) => {
if (locale === "en-US") {
return new URL(slug, process.env.NEXT_PUBLIC_BASE_URL!).toString();
} else {
return new URL(
locale + "/" + slug,
process.env.NEXT_PUBLIC_BASE_URL!
).toString();
}
};
const WebUrl = process.env.NEXT_PUBLIC_BASE_URL as string;
export async function generateMetadata(
{ params }: BlogPostPageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const [blogPagedataSeo] = await Promise.all([
client.pageBlogPost({
slug: params.slug.toString(),
locale: params.locale.toString(),
preview: draftMode().isEnabled,
}),
]);
const blogPost = blogPagedataSeo.pageBlogPostCollection?.items[0];
if (!blogPost) {
return notFound();
}
const url = generateUrl(params.locale || "", params.slug);
return {
title: blogPost.seoFields?.pageTitle,
description: blogPost.seoFields?.pageDescription,
metadataBase: new URL(WebUrl),
alternates: {
canonical: url,
languages: {
"en-US": `/${params.slug}`,
"de-DE": `/de-DE/${params.slug}`,
"x-default": `/${params.slug}`,
},
},
openGraph: {
type: "website",
siteName: "Example.dev - Free Tutorials and Resources for Developers",
locale: params.locale,
url: url || "",
title: blogPost.seoFields?.pageTitle || undefined,
description: blogPost.seoFields?.pageDescription || undefined,
images: blogPost.seoFields?.shareImagesCollection?.items.map((item) => ({
url: item?.url || "",
width: item?.width || 0,
height: item?.height || 0,
alt: item?.description || "",
type: item?.contentType || "",
})),
},
robots: {
follow: blogPost.seoFields?.follow || false,
index: blogPost.seoFields?.index || false,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
},
},
};
}
async function BlogPostPage({ params }: BlogPostPageProps) {
const { isEnabled } = draftMode(); // Check if draft mode is enabled for Contentful
let jsonLd: WithContext<Article> = {} as WithContext<Article>;
const [blogPagedata] = await Promise.all([
client.pageBlogPost({
slug: params.slug.toString(),
locale: params.locale.toString(),
preview: isEnabled,
}),
]);
const blogPost = blogPagedata.pageBlogPostCollection?.items[0];
// Create JSON-LD schema only if blogPost is available
if (blogPost) {
jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: blogPost?.title || undefined,
author: {
"@type": "Person",
name: blogPost.author?.name || undefined,
// The full URL must be provided, including the website's domain.
url: new URL(
path.join(
params.locale.toString() || "",
params.slug.toString() || ""
),
process.env.NEXT_PUBLIC_BASE_URL!
).toString(),
},
publisher: {
"@type": "Organization",
name: "Example.dev - Free Tutorials and Resources for Developers",
logo: {
"@type": "ImageObject",
url: "https://www.example.dev/favicons/icon-192x192.png",
},
},
image: blogPost?.featuredImage?.url || undefined,
datePublished: blogPost.publishedDate,
dateModified: blogPost.sys.publishedAt,
};
}
if (!blogPost) {
// If a blog post can't be found,
// tell Next.js to render a 404 page.
return notFound();
}
// Internationalization, get the translation function
const { t } = await createTranslation(params.locale as LocaleTypes, "common");
const relatedPosts = blogPost?.relatedBlogPostsCollection?.items;
if (!blogPost || !relatedPosts) return null;
let { datanew, minSize, maxSize } = {
datanew: [],
minSize: 0,
maxSize: 0,
};
const searchFacets = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/search/facets?slug=${blogPost.slug}`,
{
// next: { revalidate: 24 * 60 * 60 }, // 24 hours,
}
)
.then((res) => res.json())
.catch((error) => {
console.log("No data found");
});
if (searchFacets) {
maxSize = searchFacets.maxSize;
minSize = searchFacets.minSize;
datanew = searchFacets.datanew;
}
const views =
(await redis.get<number>(
[
"pageviews",
"projects",
params.locale.toString() + "/" + blogPost.slug,
].join(":")
)) ?? 0;
return (
<>
{blogPost && (
<Script
id="article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd),
}}
/>
)}
<div className="mt-4" />
<ReportView slug={params.locale.toString() + "/" + blogPost.slug || ""} />
<Container>
<ArticleHero
article={blogPost}
isReversedLayout={true}
isHomePage={false}
/>
</Container>
<Container className="max-w-5xl mt-8">
{/* Tag Cloud Integration */}
{searchFacets && datanew.length > 0 && (
<TagCloudSimpleHome
datanew={datanew}
minSize={minSize * 10}
maxSize={maxSize * 5}
locale={params.locale}
source={"blog"}
/>
)}
<div className="mt-4" />
<ArticleContent article={blogPost} />
</Container>
{relatedPosts.length > 0 && (
<Container className="max-w-5xl mt-8">
{/* Without internationalization: */}
{/* <h2 className="mb-4 md:mb-6">Related Posts</h2> */}
{/* With internationalization: */}
<h2 className="mb-4 md:mb-6">{t("blog.relatedArticles")}</h2>
<ArticleTileGrid
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
articles={relatedPosts}
slug=""
source="relatedposts"
locale={params.locale.toString()}
/>
</Container>
)}
<Container className="max-w-5xl mt-8">
<ClapButton slug={blogPost.slug || ""} />
<div className="text-base p-2 dark:bg-gray-500 rounded-lg dark:text-white bg-gray-200 text-gray-600 w-24 mt-4">
Views: {views}
</div>
</Container>
</>
);
}
export default BlogPostPage;That’s it. We built and integrated an easy and lightning-fast view counter within minutes. Of course, there is space for a style improvement, etc., but from a functional POV, it works great.

Cloudapp-dev, and before you leave us
Thank you for reading until the end. Before you go:


