Loading...
Nextjs14-Viewcounter
Author Cloudapp
E.G.

Next.js 14 - View Counter for Blog with Serverless Redis (Upstash)

June 12, 2024
Table of Contents

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/redis

Vercel 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.

SEO-Title-Blog-Post
SEO-Title-Blog-Post

Cloudapp-dev, and before you leave us

Thank you for reading until the end. Before you go:

Please consider clapping and following the writer! 👏 on our Medium Account

Related articles