Loading...
Nextjs14-Custom-Tracking-Script
Author Cloudapp
E.G.

Next.js 14 -Building Custom Client-Side Tracking End-to-End

August 20, 2024
Table of Contents

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/fingerprintjs

Changing 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 push

after 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:

Please consider clapping and following the writer! 👏 on our Medium Account

Or follow us on twitter -> Cloudapp.dev

Related articles