surreal.sh

Adding a view counter in Nextjs 14 using ioredis

In this article I will explain how I added (hacked in) view counters using Nextjs 14 (TypescriptšŸ¤¢) (app directory structurešŸ„±) and Redis.

codeguidetutorialnextjsioredisarticle
neon @05 Nov 2023

0


Prequesites

I'll assume you know atleast how to install the necessary libraries and setup your coding environment. If not please refer to a different guide before proceeding with this one (for your own sake and sanity).

The stack

Well currently the only thing that matters is:

  1. You are using Nextjs
  2. You are using the app directory structurešŸ„±
  3. You are using TypescriptšŸ¤¢
  4. You have a page already setup with some sort of content (does not need to be a blog post site)
  5. You have a running redis instance locally or on your server
  6. You have installed ioredis using a package manager of your choice.

The structure

I bascially copied a principle I found on chronark's website but fixed it up and upgraded it a bit. It's not perfect but it will do for now.

We will create 2 files in different directories:

In the app/api directory we will make a folder named view and in it a file called route.ts.

In the app/components directory we will make a file named view.tsx.

Note the difference in extensions as linters and compilers will give warnings and or errors and stuff may or may not work otherwise.

API components

Basically we will create 2 functions in the same file. One for the GET request and one for the POST.

GET

The GET function is simple. All you need to do is attempt to connect to redis and execute a mget then return that value or 0. It's pretty self explanatory.

export async function GET(req: Request): Promise<Response> {
  try {
    const redis = new Redis(process.env.REDIS_URL || 'localhost:6379', {
      lazyConnect: true,
    })

    redis.connect()

    const url = new URL(req.url);
    const slug = url.searchParams.get('slug');

    if (slug) {
      let view = await redis.mget(["pageviews", slug].join(":")) ?? 0;
      let data = {
        view: Number(view[0])
      }
      return new Response(JSON.stringify(data), { status: 200 })
    }
    
    return new Response("No data :(", { status: 404 })

  } catch {
    return new Response("Redis is down", { status: 500 })
  }

  return new Response(JSON.stringify(views), { status: 200 })
}

POST

This one is a bit more complicated since it includes a "deduplication" portion. Keep in mind this is not optimal and the actual deduplication is different for me but you'll figure it out on your own.

export async function POST(req: Request): Promise<Response> {
	if (headers().get("Content-Type") !== "application/json") {
		return new Response("Must be json", { status: 400 });
	}

	const body = await req.json()
	let slug: string | undefined = undefined;
	if ("slug" in body) {
		slug = body.slug;
	}
	if (!slug) {
		return new Response("Slug not found", { status: 400 });
	}

	try {
		const redis = new Redis(process.env.REDIS_URL || 'localhost:6379', {
			lazyConnect: true,
			maxRetriesPerRequest: 0,
		})

		redis.connect()

		const ip = headers().get("X-Real-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.setnx(["deduplicate", hash, slug].join(":"), 1);
    
    if (isNew != 1) {
      new Response(null, { status: 202 });
    }

    await redis.setex(["deduplicate", hash, slug].join(":"), 24*60*60, 1)
		await redis.incr(["pageviews", slug].join(":"));

		return new Response(null, { status: 202 });

	} catch {
		return new Response("Redis is down", { status: 500 });
	}
}

Client components

ReportView element

This just sends a request to the api writting a view. How do you use it? Simple, put it into the page you want to be tracked, and specify its slug or id.

What is this abortController magic I see?

Basically React when you are developing hydrates the same area twice so you expose potential bugs. For a view counter its annoying as that means your ViewCount will get called multiple times, which is not important for the displaying of a count but for writting it it gets annoying. This just ensures only one request gets served unless you nest hydration areas. Idk that deep so you'll have to do you own homework.

export const ReportView: React.FC<{ slug: string }> = ({ slug }) => {
	useEffect(() => {
		const abortController = new AbortController();
		const incrementView = async () => {
			try {
				await fetch("/api/view", {
					method: "POST",
					headers: {
						"Content-Type": "application/json",
					},
					body: JSON.stringify({ slug }),
					signal: abortController.signal
				});
			} catch (error) {
				
			}
		}
		incrementView();
		return () => abortController.abort();
	}, [slug]);

	return null;
};

ViewCount element

This is the clientside element that is responsible for rendering how many views a site has. It accepts a slug as its input string but it can be anything, like a cuid() for example.

The abortController isn't necessary here.

export const ViewCount: React.FC<{ slug: string }> = ({ slug }) => {
  const [view, setView] = useState<number>(0);

	useEffect(() => {
		const abortController = new AbortController();
		const getView = async () => {
			try {
				const res = await fetch(`/api/view?slug=${slug}`, {
					method: "GET",
					headers: {
						"Content-Type": "application/json",
					},
					signal: abortController.signal
				});
				const data = await res.json();
        setView(data.view);
			} catch (error) {
				console.error(error)
			}
		}
		getView();
		return () => abortController.abort();
	}, [slug]);

	return (
    <span>
      {Intl.NumberFormat("en-US", { notation: "compact" }).format(view)}
    </span>
	);
};

Conclusion

Yea thats pretty much it if you have any question email me or sum'n.