Creating a Human vs AI Auto Blogger - Part 4 - Set up 'View Count' and 'Time to Read' with Upstash

Creating a Human vs AI Auto Blogger - Part 4 - View Count & Minutes to Read Post with Upstash
This is vendor lock-in with Upstash (free to start) for the small bonus of having a view count and an average ‘minutes to read’ calculator…which I kind of want!
I used Hosna’s post who was inspired by Andreas' blog post, which was inspired by Lee's blog. These provide a more in depth overview on how this works but the overview is:
- Naviagate to /[slug]/page.tsx
- ReportView component fires an API POST in app/api/increment/route.ts to our Upstash redis db
- Upstash redis stores our view count
- We read our view count at top of page
- We calculate the read time based on number of words on page
Let’s dive into it! The first thing we are going to do is get our API keys from Upstash, let’s go and create a Redis db.
The free plan should be enough for our needs.
Scroll down after you are back to the dashboard and copy your env keys
Store these in your .env file
Now open up the terminal and install the npm package for Upstash:
npm i @upstash/redis
Once installed, let’s create our report view component which will be a function that returns null but tells redis to increment our view count via the api route we are about to create after. First make this component in /src/components/blog/report-view.tsx
'use client';
import { useEffect } from 'react';
export const ReportView: React.FC<{ slug: string }> = ({ slug }) => {
useEffect(() => {
fetch('/api/increment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ slug }),
});
}, [slug]);
return null;
};
Now let’s make the api increment route in /src/app/api/increment/route.ts
import { Redis } from "@upstash/redis";
export var config = {
runtime: "edge",
};
const redis = Redis.fromEnv();
export async function POST(req: Request) {
const body = await req.json();
const { slug } = body;
if (!slug) {
return new Response("Slug is not provided", { status: 404 });
}
const ip = req.headers.get("x-forwarded-for") ?? "";
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){
return new Response('User has already viewed the page' , {status:202})
}
const res = await redis.incr(["pageviews", "projects", slug].join(":"));
console.log('The number is : ' , res)
return new Response('The user has successfully added to the viewer list', {status: 200})
}
Here we are checking that the IP address is actually unique/new, and if it is, we increment the pageviews in Redis.
We now have a component that fires an increment for new viewers every time they visit that page. Now lets set up the calculate read time and a function to format the date.
We add our format date and calculate read time functions in our /src/lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(input: string | number): string {
const date = new Date(input)
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
export function reformatDate(dateStr: string) {
// Split the input date string to get year, month, and day
const parts = dateStr.split('-').map((part) => parseInt(part, 10));
// Create a new Date object using UTC values
// Note: The month argument is 0-indexed (0 is January, 1 is February, etc.)
const date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
// Correctly typed options for toLocaleDateString
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC', // Specify UTC as the timezone
};
return date.toLocaleDateString('en-US', options);
}
export function calculateReadingTime(mdxContent: any) {
// Define the average reading speed (words per minute)
const wordsPerMinute = 200;
// Strip MDX/HTML tags and count the words
const text = mdxContent.replace(/<\/?[^>]+(>|$)/g, ''); // Basic stripping of HTML tags
const wordCount = text
.split(/\s+/)
.filter((word: any) => word.length > 0).length;
// Calculate reading time
const readingTime = Math.ceil(wordCount / wordsPerMinute);
return readingTime;
}
The calculate minutes to read is based on the number of words in the article divided by the average words a person can read per minute (200).
Next, let’s call these functions as well as our report views component within our /src/app/(blog)/portfolio/[slug]/page.tsx for our human made posts
import { Mdx } from '@/components/blog/mdx-wrapper'
import { ReportView } from '@/components/blog/view-report'
import { Button, buttonVariants } from '@/components/ui/button'
import { calculateReadingTime, cn, formatDate } from '@/lib/utils'
import { Redis } from '@upstash/redis'
import { allPortfolios, allPosts } from 'contentlayer/generated'
import { ChevronLeft, EyeIcon } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import React, { FC } from 'react'
import { Metadata } from 'next';
interface PostProps {
params: {
slug: string
}
}
async function getDocFromParams(slug: string){
const doc = allPortfolios.find((doc) => doc.slug === slug)
if(!doc) notFound()
return doc
}
const redis = Redis.fromEnv();
const Post = async ({params}: PostProps) => {
const doc = await getDocFromParams(params.slug)
const views =
(await redis.get<number>(['pageviews', 'projects', doc.slug].join(':'))) ??
0;
return (
<div>
<ReportView slug={doc.slug} />
<div className='rounded-xl mx-auto max-w-5xl mt-10 relative'>
<Image src={doc?.image.src} width={doc?.image.width} height={doc?.image.height} alt={doc?.title} className='rounded-xl z-0'/>
<div className='absolute top-20 mx-20 text-center p-5 rounded-lg bg-white/20 z-10 text-slate-900 font-extrabold text-3xl'>{doc?.title}</div>
</div>
<div className='my-2 text-center'>
{doc.date && (
<time
dateTime={doc.date}
className="block ml-auto text-md text-muted-foreground"
>
<EyeIcon className="inline w-4 h-4 text-muted-foreground mr-1" />
{Intl.NumberFormat('en-US', { notation: 'compact' }).format(
views,
)}{' '}
{' views'}
<span className="mx-1"></span> {` `} ·{" "} <span className="mx-1"></span> {formatDate(doc.date)} <span className="mx-1"></span> {` `} ·{" "}
<span className='mx-1'>
{calculateReadingTime(doc.body.code)}
{' min read'}
</span>
</time>
)}
</div>
<Mdx code={doc.body.code}/>
</div>
)
}
export default Post
and in /src/app/(blog)/posts/[slug]/page.tsx for our AI made blog posts.
import { Mdx } from '@/components/blog/mdx-wrapper'
import { ReportView } from '@/components/blog/view-report'
import { buttonVariants } from '@/components/ui/button'
import { calculateReadingTime, cn, formatDate } from '@/lib/utils'
import { Redis } from '@upstash/redis'
import { allPosts } from 'contentlayer/generated'
import { ChevronLeft, EyeIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import React, { FC } from 'react'
import { toast } from 'sonner'
interface PostProps {
params: {
slug: string
}
}
async function getDocFromParams(slug: string){
const doc = allPosts.find((doc) => doc.slugAsParams === slug)
if(!doc) notFound()
return doc
}
const redis = Redis.fromEnv();
const Post = async ({params}: PostProps) => {
const doc = await getDocFromParams(params.slug)
const views =
(await redis.get<number>(['pageviews', 'projects', doc.slug].join(':'))) ??
0;
return (
<div>
<ReportView slug={doc.slug} />
<div className='text-center'>
{doc.date && (
<time
dateTime={doc.date}
className="block ml-auto text-md text-muted-foreground"
>
<EyeIcon className="inline w-4 h-4 text-muted-foreground mr-1" />
{Intl.NumberFormat('en-US', { notation: 'compact' }).format(
views,
)}{' '}
{' views'}
<span className="mx-1"></span> {` `} ·{" "} <span className="mx-1"></span> {formatDate(doc.date)} <span className="mx-1"></span> {` `} ·{" "}
<span className='mx-1'>
{calculateReadingTime(doc.body.code)}
{' min read'}
</span>
</time>
)}
</div>
<Mdx code={doc.body.code}/>
</div>
)
}
export default Post
Now we should be seeing our view count change when we visit the page but not change on refresh.
And that’s it folks!
Congrats on Finishing Part 4!
With that complete, you now have the a blog ready for your website using Notion & OpenAI.
This is just the start, subscribe for more courses on how to add to this project and build your next successful venture.