Inhaltsverzeichnis
Inspiriert von vielen Blogs, die auf jeder Seite einen hübschen Zähler für die Seitenaufrufe anzeigen, wollte ich eine solche Funktion auch für meinen Blog entwickeln. Ich verwende auch Next.js 14 mit dem neuen App-Router, aber anstatt die Seitenaufrufe in einer relationalen Datenbank wie PostgreSQL zu speichern, werde ich Upstash Redis verwenden.
Hier ist das GitHub repo mit dem vollständigen Code.
Beispielseite -> https://nextjs14-azureb2c-prisma.vercel.app/
Warum Redis und nicht eine relationale DB wie PostgreSQL?
Redis bietet großartige Befehle, die das Deduplizieren und Inkrementieren eines Zählers erleichtern, was für die Genauigkeit entscheidend ist.
Ich möchte das Inkrementieren des Zählers entprellen, um einen genaueren Zähler zu erhalten. Wenn ein Benutzer die Seite auffrischt, sollte der Zähler nur einmal erhöht werden. Dies lässt sich mit dem SET-Befehl von Redis leicht bewerkstelligen. Er hat eine NX-Option, die den Schlüssel nur setzt, wenn er noch nicht existiert, und eine EX-Option, die den Schlüssel nach einer bestimmten Anzahl von Sekunden ablaufen lässt. Indem wir beide Optionen kombinieren, können wir sicherstellen, dass ein einzelner Benutzer den Zähler innerhalb eines bestimmten Zeitraums nicht mehrfach erhöhen kann.
Der zweite Befehl ist INCR, der einen gegebenen Schlüssel atomar um 1 inkrementiert. Und zu guter Letzt können wir diese Aufgabe von unserer "Standard"-Relationalen-DB auslagern.
Next.js Api Route für Redis-Operationen
Wir erstellen die Datei src/app/api/viewcount, in die wir unsere Redis Lib-Datei importieren, die wir im vorherigen Beitrag erstellt haben.
// 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-Paket
Das Gleiche gilt für das benötigte Upstash Redis NPM-Paket, das wir im vorherigen Beitrag installiert haben.
npm i @upstash/redisVercel EDGE
Upstash und @upstash/redis sind mit den Edge-Funktionen von Vercel kompatibel, also importieren wir zunächst alles, was wir brauchen, richten Redis ein und konfigurieren die Runtime als 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 });
}Neue Tracking-Komponente "viewcount.tsx"
Wir übergeben das Slug-Attribut an die zuvor erstellte API-Route mit "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;
};Projektintegration "page.tsx"
Als letzten Schritt müssen wir die neue Komponente in unser Next.js 14 Projekt integrieren.
Redis Lib und viewcount Komponente importieren
// ViewCount
import { ReportView } from "@/components/analytics/viewcount";
import redis from "../../../lib/redis";Definieren der Konstanten Views in Zeile 222
const views =
(await redis.get<number>(
[
"pageviews",
"example",
params.locale.toString() + "/" + blogPost.slug,
].join(":")
)) ?? 0;Verwendung einer importierten Komponente in Zeile 243
<ReportView slug={params.locale.toString() + "/" + blogPost.slug || ""} />Anzeigen von Ansichten auf der Unterseite (Zeile 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>Nachstehend der vollständige 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;Das war's. Wir haben innerhalb weniger Minuten einen einfachen und blitzschnellen View Counter gebaut und integriert. Natürlich gibt es Raum für eine Stilverbesserung, etc., aber von einem funktionalen POV, es funktioniert großartig.

Cloudapp-dev und bevor Sie uns verlassen
Danke, dass Sie bis zum Ende gelesen haben. Noch eine Bitte bevor Sie gehen:


