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.
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:
- You are using Nextjs
- You are using the app directory structureš„±
- You are using Typescriptš¤¢
- You have a page already setup with some sort of content (does not need to be a blog post site)
- You have a running redis instance locally or on your server
- 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.