Table of Contents
- NPM Package for Fingerprinting
- Changing Prisma Schema
- Syncing Schema Changes
- Creating a new Tracking Hook
- New Component for Hook Integration
- Importing new Component into Layout
- Layout.tsx — Complete Code
- Saving Tracking Data to Postgres DB via Custom Route
- Tracking Route — Complete Code
- Cloudapp-dev, and before you leave us
In this story, I will show you how you can build a custom hook for page tracking and integrate it into your existing Next.js 14 project with ease. The tracked data will be saved into a Neon.tech Postgres DB via the ORM Prisma and a custom API Route.
Here is the GitHub repo with the entire code. Below, you will find the link to the example page.
Example page hosted on Vercel -> https://nextjs14-kafka-tracking.vercel.app/
NPM Package for Fingerprinting
If you read the first story, you have already installed the needed NPM packages.
npm i @fingerprintjs/fingerprintjsChanging Prisma Schema
Adding new Data Fields to our schema.prisma File
model tracking {
id Int @id @default(autoincrement())
country String?
city String?
region String?
url String?
nexturl String?
ip String?
pathname String?
mobile String?
platform String?
useragent String?
referer String?
fetchsite String?
created_at DateTime?
sessionId String?
userId String?
pageViewId String?
addedOn DateTime @default(now())
}Syncing Schema Changes
npx prisma db pushafter Schema changes, so that the change was synchronised with the underlying DB.
Creating a new Tracking Hook
Now we are ready for the creation of our new hook, where we use the new npm package “fingerprintjs”, which we use to define a UserID.
//src/lib/hooks/usePageTracking.ts
"use client";
import { useEffect, useState } from "react";
import FingerprintJS from "@fingerprintjs/fingerprintjs";
const apikey = process.env.API_KEY;
// Define the type for the page view data
interface PageViewData {
pageViewID: string;
sessionID: string;
userID: string;
url: string;
referrer: string;
timestamp: string;
country: string;
}
// Define the type for the time spent data
interface TimeSpentData {
sessionID: string;
userID: string;
timeSpent: number;
url: string;
}
function createSessionID(): string {
return "session-" + Math.random().toString(36).substr(2, 16);
}
function createPageViewID(): string {
return "view-" + Math.random().toString(36).substr(2, 16);
}
function getSessionID(): string {
let sessionId = localStorage.getItem("session_id");
if (!sessionId) {
sessionId = createSessionID();
localStorage.setItem("session_id", sessionId);
}
return sessionId;
}
async function sendToPrisma(message: any) {
// Pushing tracking Info direct to Postgres
await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/user/tracking`, {
method: "POST",
body: JSON.stringify(message),
headers: new Headers({
"Content-Type": "application/json" || "",
"x-api-key": apikey || "",
}),
});
}
async function getUserID(): Promise<string> {
const fpPromise = FingerprintJS.load();
const fp = await fpPromise;
const result = await fp.get();
return result.visitorId; // This is the anonymous user ID
}
async function getUserCountry(): Promise<string> {
try {
const response = await fetch("https://ipapi.co/json/");
const data = await response.json();
return data.country_name || "Unknown";
} catch (error) {
console.error("Error fetching country:", error);
return "Unknown";
}
}
async function capturePageView(userID: string): Promise<void> {
const pageViewID = createPageViewID();
const sessionID = getSessionID();
const url = window.location.href;
const referrer = document.referrer;
const timestamp = new Date().toISOString();
const country = await getUserCountry();
const pageViewData: PageViewData = {
pageViewID: pageViewID,
sessionID: sessionID,
userID: userID,
url: url,
referrer: referrer,
timestamp: timestamp,
country: country,
};
sendDataToServer(pageViewData);
}
function sendDataToServer(data: PageViewData | TimeSpentData): void {
const trackdata: any = data;
const message = {
country: trackdata.country,
// city: request.geo?.city,
// region: request.geo?.region,
// pathname: request.nextUrl.pathname,
url: trackdata.url,
// nexturl: request.headers.get("next-url"),
// mobile: request.headers.get("sec-ch-ua-mobile"),
// platform: request.headers.get("sec-ch-ua-platform"),
// useragent: request.headers.get("user-agent"),
referer: trackdata.referrer,
userId: trackdata.userID,
pageViewId: trackdata.pageViewID,
sessionId: trackdata.sessionID,
created_at: trackdata.timestamp,
};
sendToPrisma(message);
}
export default function usePageTracking(): void {
const [userID, setUserID] = useState<string | null>(null);
useEffect(() => {
// Initialize user ID and start tracking
const initializeTracking = async () => {
const id = await getUserID();
setUserID(id);
await capturePageView(id);
};
initializeTracking();
const pageLoadTime = Date.now();
const handleBeforeUnload = () => {
if (userID) {
const timeSpent = Date.now() - pageLoadTime;
const timeSpentData: TimeSpentData = {
sessionID: getSessionID(),
userID: userID,
timeSpent: timeSpent,
url: window.location.href,
};
sendDataToServer(timeSpentData);
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [userID]);
}New Component for Hook Integration
Now, we create a new Component that uses the new hook, which we then import into our layout.tsx
//src/components/analytics/customTracking.tsx
"use client";
import usePageTracking from "@/lib/hooks/usePageTracking";
const PageTracker: React.FC = () => {
usePageTracking(); // Hook is called here
return null; // This component doesn't need to render anything
};
export default PageTracker;Importing new Component into Layout
Now we import the component src/app/[locale]/layout.tsx
// Custom Tracking Script/Hooks
import CustomTracking from "@/components/analytics/customTracking";and below {children} we use the component.
{/* Custom Tracking Hook */}
<CustomTracking />Layout.tsx — Complete Code
import type { Metadata } from "next";
import { Urbanist } from "next/font/google";
import { draftMode } from "next/headers";
import "instantsearch.css/themes/satellite-min.css";
import "@/app/globals.css";
import Header from "@/components/header/header.component";
import Footer from "@/components/footer/footer.component";
import { Providers } from "@/components/header/providers";
import getAllNavitemsForHome from "@/components/header/navbar.menuitems.component";
import getAllFooteritemsForHome from "@/components/footer/footer.menuitems.component";
import ExitDraftModeLink from "@/components/header/draftmode/ExitDraftModeLink.component";
import { locales } from "@/app/i18n/settings";
//Contentful Client
import { client } from "@/lib/client";
// Custom Tracking Script/Hooks
import CustomTracking from "@/components/analytics/customTracking";
// Auth AD B2C
import { NextAuthProvider } from "@/app/providers";
const urbanist = Urbanist({ subsets: ["latin"], variable: "--font-urbanist" });
export async function generateStaticParams() {
return locales.map((lng) => ({ lng }));
}
export const metadata: Metadata = {
title: "Example Blog",
description: "Your Example Blog Description",
icons: {
icon: [
{ rel: "icon", url: "/favicons/favicon-16x16.png", sizes: "16x16" },
new URL("/favicons/favicon-16x16.png", process.env.NEXT_PUBLIC_BASE_URL),
{ rel: "icon", url: "/favicons/favicon-32x32.png", sizes: "32x32" },
new URL("/favicons/favicon-32x32.png", process.env.NEXT_PUBLIC_BASE_URL),
],
shortcut: [{ rel: "shortcut icon", url: "/favicons/favicon.ico" }],
apple: [
{
url: "/favicons/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
],
},
};
type LayoutProps = {
children: React.ReactNode;
params: { locale: string };
};
export default async function RootLayout({ children, params }: LayoutProps) {
const locale = params.locale;
const htmlLang = locale === "en-US" ? "en" : "de";
const headerdata = await getAllNavitemsForHome(locale);
const footerdata = await getAllFooteritemsForHome(locale);
// Get the landing page data for the logo
const { isEnabled } = draftMode();
const landingPageData = await client.pageLanding({
locale,
preview: isEnabled,
slug: "/",
});
const page = landingPageData.pageLandingCollection?.items[0];
const logourl = page?.logo?.url || "";
return (
<html lang={htmlLang} suppressHydrationWarning>
<head></head>
<body>
<main className={`${urbanist.variable} font-sans dark:bg-gray-900`}>
<NextAuthProvider>
<Providers>
<Header showBar={true} menuItems={headerdata} logourl={logourl} />
{draftMode().isEnabled && (
<p className="bg-emerald-400 py-4 px-[6vw]">
Draft mode is on! <ExitDraftModeLink className="underline" />
</p>
)}
{children}
{/* Custom Tracking Hook */}
<CustomTracking />
<Footer footerItems={footerdata} />
</Providers>
</NextAuthProvider>
</main>
</body>
</html>
);
}Saving Tracking Data to Postgres DB via Custom Route
We use the new route in our previously created hook
async function sendToPrisma(message: any) {
// Pushing tracking Info direct to Postgres
await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/user/tracking`, {
method: "POST",
body: JSON.stringify(message),
headers: new Headers({
"Content-Type": "application/json" || "",
"x-api-key": apikey || "",
}),
});
}Tracking Route — Complete Code
The Route gets the data and stores it in the corresponding “tracking” table. Since we are using Prisma, it’s quite simple.
//src/app/api/user/tracking/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const {
country,
city,
region,
pathname,
url,
nexturl,
ip,
mobile,
platform,
useragent,
referer,
fetchsite,
created_at,
userId,
pageViewId,
sessionId,
} = await req.json();
let data = await prisma.tracking.create({
data: {
country: country,
city: city,
region: region,
pathname: pathname,
url: url,
nexturl: nexturl,
ip: ip,
mobile: mobile,
platform: platform,
useragent: useragent,
referer: referer,
fetchsite: fetchsite,
created_at: created_at,
userId: userId,
pageViewId: pageViewId,
sessionId: sessionId,
},
});
return NextResponse.json({
data,
});
}Cloudapp-dev, and before you leave us
Thank you for reading until the end. Before you go:

