HomeBlogView Counter Upstash

View Counter with Upstash Redis

6min
0 views

This guide is on my previous post

In this post, I'll show you how to add a view counter to your Next.js blog using Upstash Redis. We'll focus on two main goals: (1) displaying the number of views for each blog post, and (2) incrementing the view count each time someone visits a page. Let's dive in and enhance your blog with this interactive feature!

Setting up Upstash Redis

  1. Create an Upstash account.
  2. Create a new Redis database.
  3. In the REST API section, click the .env button to copy your environment variables.
UPSTASH_REDIS_REST_URL=""
UPSTASH_REDIS_REST_TOKEN=""
UPSTASH_REDIS_REST_URL=""
UPSTASH_REDIS_REST_TOKEN=""

Configuring Upstash

Install the Upstash Redis package in your Next.js app:

npm install @upstash/redis
npm install @upstash/redis

Implementing the View Counter

Create an API route to handle saving user visits in the Redis database:

/app/api/increment/route.ts
import { Redis } from "@upstash/redis";
import { NextResponse, type NextRequest } from "next/server";
 
const redis = Redis.fromEnv();
export const runtime = "edge";
 
export async function POST(req: NextRequest): Promise<NextResponse> {
  const body = await req.json();
  let slug: string | undefined = undefined;
  if ("slug" in body) {
    slug = body.slug;
  }
  if (!slug) {
    return new NextResponse("Slug not found", { status: 400 });
  }
 
  const ip = req.ip;
  if (ip) {
    // Hash the IP in order to not store it directly in your db.
    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("");
 
    // deduplicate the ip for each slug
    const isNew = await redis.set(["deduplicate", hash, slug].join(":"), true, {
      nx: true,
      ex: 24 * 60 * 60,
    });
 
    if (isNew) {
      await redis.incr(["pageviews", "blog", slug].join(":"));
      return new NextResponse(null, { status: 202 });
    } else {
      return new NextResponse("View already counted", { status: 202 });
    }
  }
 
  return new NextResponse(null, { status: 202 });
}
/app/api/increment/route.ts
import { Redis } from "@upstash/redis";
import { NextResponse, type NextRequest } from "next/server";
 
const redis = Redis.fromEnv();
export const runtime = "edge";
 
export async function POST(req: NextRequest): Promise<NextResponse> {
  const body = await req.json();
  let slug: string | undefined = undefined;
  if ("slug" in body) {
    slug = body.slug;
  }
  if (!slug) {
    return new NextResponse("Slug not found", { status: 400 });
  }
 
  const ip = req.ip;
  if (ip) {
    // Hash the IP in order to not store it directly in your db.
    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("");
 
    // deduplicate the ip for each slug
    const isNew = await redis.set(["deduplicate", hash, slug].join(":"), true, {
      nx: true,
      ex: 24 * 60 * 60,
    });
 
    if (isNew) {
      await redis.incr(["pageviews", "blog", slug].join(":"));
      return new NextResponse(null, { status: 202 });
    } else {
      return new NextResponse("View already counted", { status: 202 });
    }
  }
 
  return new NextResponse(null, { status: 202 });
}

This API route does the following:

  1. Extracts the slug from the request body.
  2. Hashes the visitor's IP address for privacy.
  3. Uses Redis to deduplicate views based on the hashed IP and slug.
  4. Increments the view count only for new views *not seen in the last 24 hours.

Redis provides two key commands that make this process efficient:

  • SET with the NX option: Sets a key only if it doesn't exist, perfect for deduplication.
  • INCR: Atomically increments a counter, ideal for tracking views. Redis provides two key commands that make this process efficient:

Reporting Views

Create a client-side component to call the API when a user visits a blog post:

/app/blog/[...slug]/report-view.tsx
"use client";
 
import { useEffect } from "react";
 
export const ReportView: React.FC<{ slug: string }> = ({ slug }) => {
  useEffect(() => {
    void fetch("/api/increment", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ slug }),
    });
  }, [slug]);
 
  return null;
};
/app/blog/[...slug]/report-view.tsx
"use client";
 
import { useEffect } from "react";
 
export const ReportView: React.FC<{ slug: string }> = ({ slug }) => {
  useEffect(() => {
    void fetch("/api/increment", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ slug }),
    });
  }, [slug]);
 
  return null;
};

Displaying View Count

Create another component to fetch and display the view count for each blog post:

/app/blog/[...slug]/show-view.tsx
import { Redis } from "@upstash/redis";
import React from "react";
 
const redis = Redis.fromEnv();
export const revalidate = 0;
 
const ShowViews = async ({ slug }: { slug: string }) => {
  const views =
    (await redis.get<number>(["pageviews", "blog", slug].join(":"))) ?? 0;
  return <div>{views} views</div>;
};
 
export default ShowViews;
/app/blog/[...slug]/show-view.tsx
import { Redis } from "@upstash/redis";
import React from "react";
 
const redis = Redis.fromEnv();
export const revalidate = 0;
 
const ShowViews = async ({ slug }: { slug: string }) => {
  const views =
    (await redis.get<number>(["pageviews", "blog", slug].join(":"))) ?? 0;
  return <div>{views} views</div>;
};
 
export default ShowViews;

Import and use these components in your blog post page:

/app/blog/[...slug]/page.tsx
import { allPosts } from "contentlayer/generated";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { ReportView } from "./report-view";
import ShowViews from "./show-view";
 
export default function Blog({ params }) {
  // ... (post fetching logic)
 
  return (
    <section className="flex items-center justify-center w-full flex-col p-8">
      <ReportView slug={post.slug} />
      <div className="w-full max-w-3xl">
        <h1 className="title font-medium text-2xl md:text-4xl tracking-tighter font-heading">
          {post.title}
        </h1>
        <Suspense fallback={<div className="blur-sm">100 views</div>}>
          <ShowViews slug={post.slug} />
        </Suspense>
        // ... (rest of your blog post content)
      </div>
    </section>
  );
}
/app/blog/[...slug]/page.tsx
import { allPosts } from "contentlayer/generated";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { ReportView } from "./report-view";
import ShowViews from "./show-view";
 
export default function Blog({ params }) {
  // ... (post fetching logic)
 
  return (
    <section className="flex items-center justify-center w-full flex-col p-8">
      <ReportView slug={post.slug} />
      <div className="w-full max-w-3xl">
        <h1 className="title font-medium text-2xl md:text-4xl tracking-tighter font-heading">
          {post.title}
        </h1>
        <Suspense fallback={<div className="blur-sm">100 views</div>}>
          <ShowViews slug={post.slug} />
        </Suspense>
        // ... (rest of your blog post content)
      </div>
    </section>
  );
}

Folder Structure


route.ts
page.tsx
report-view.tsx
show-view.tsx
page.tsx

That's it! You've successfully implemented a view counter in your Next.js application. The view count will increment once every 24 hours per unique visitor for each blog post, and the updated count will be displayed on the page.

    References
  1. Upstash, "Adding a View Counter to your Next.js Blog".