Skip to main content
Back to Blog
Tutorials
4 min read
December 11, 2024

How to Build a Real-Time Analytics Dashboard in Next.js

Build a real-time analytics dashboard with live visitor counts, page views, and event tracking using Server-Sent Events in Next.js.

Ryel Banfield

Founder & Lead Developer

Real-time analytics show what is happening on your site right now. This tutorial uses Server-Sent Events (SSE) for live updates without WebSocket complexity.

Analytics Event Tracking

// lib/analytics.ts
import { db } from "@/db";
import { events } from "@/db/schema";

interface TrackEvent {
  name: string;
  path: string;
  referrer?: string;
  userAgent?: string;
  sessionId: string;
  properties?: Record<string, string>;
}

export async function trackEvent(event: TrackEvent) {
  await db.insert(events).values({
    name: event.name,
    path: event.path,
    referrer: event.referrer ?? null,
    userAgent: event.userAgent ?? null,
    sessionId: event.sessionId,
    properties: event.properties ?? {},
    createdAt: new Date(),
  });
}

export async function getRealtimeStats() {
  const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
  const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);

  // Active visitors (unique sessions in last 5 min)
  const activeResult = await db.execute(
    `SELECT COUNT(DISTINCT session_id) as count FROM events WHERE created_at > $1`,
    [fiveMinutesAgo]
  );

  // Page views in last 24h
  const viewsResult = await db.execute(
    `SELECT COUNT(*) as count FROM events WHERE name = 'pageview' AND created_at > $1`,
    [twentyFourHoursAgo]
  );

  // Top pages (last 24h)
  const topPages = await db.execute(
    `SELECT path, COUNT(*) as views FROM events WHERE name = 'pageview' AND created_at > $1 GROUP BY path ORDER BY views DESC LIMIT 10`,
    [twentyFourHoursAgo]
  );

  // Views per hour (last 24h)
  const viewsPerHour = await db.execute(
    `SELECT date_trunc('hour', created_at) as hour, COUNT(*) as views FROM events WHERE name = 'pageview' AND created_at > $1 GROUP BY hour ORDER BY hour`,
    [twentyFourHoursAgo]
  );

  // Top referrers (last 24h)
  const referrers = await db.execute(
    `SELECT referrer, COUNT(*) as count FROM events WHERE referrer IS NOT NULL AND referrer != '' AND created_at > $1 GROUP BY referrer ORDER BY count DESC LIMIT 10`,
    [twentyFourHoursAgo]
  );

  return {
    activeVisitors: Number(activeResult.rows[0]?.count ?? 0),
    pageViews24h: Number(viewsResult.rows[0]?.count ?? 0),
    topPages: topPages.rows as { path: string; views: number }[],
    viewsPerHour: viewsPerHour.rows as { hour: string; views: number }[],
    referrers: referrers.rows as { referrer: string; count: number }[],
    updatedAt: new Date().toISOString(),
  };
}

SSE Endpoint for Live Updates

// app/api/analytics/stream/route.ts
import { getRealtimeStats } from "@/lib/analytics";

export const dynamic = "force-dynamic";

export async function GET() {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      async function sendUpdate() {
        try {
          const stats = await getRealtimeStats();
          const data = `data: ${JSON.stringify(stats)}\n\n`;
          controller.enqueue(encoder.encode(data));
        } catch (error) {
          console.error("Analytics stream error:", error);
        }
      }

      // Send initial data
      await sendUpdate();

      // Update every 10 seconds
      const interval = setInterval(sendUpdate, 10000);

      // Clean up on close
      const cleanup = () => {
        clearInterval(interval);
        try {
          controller.close();
        } catch {
          // Already closed
        }
      };

      // AbortController not available in all environments
      // The interval will be cleared when the stream ends
      setTimeout(cleanup, 5 * 60 * 1000); // 5 min max connection
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

Hook for SSE Connection

// hooks/use-event-source.ts
"use client";

import { useEffect, useState, useCallback, useRef } from "react";

export function useEventSource<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [connected, setConnected] = useState(false);
  const eventSourceRef = useRef<EventSource | null>(null);

  const connect = useCallback(() => {
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
    }

    const es = new EventSource(url);
    eventSourceRef.current = es;

    es.onopen = () => setConnected(true);

    es.onmessage = (event) => {
      try {
        const parsed = JSON.parse(event.data) as T;
        setData(parsed);
      } catch {
        // Invalid JSON
      }
    };

    es.onerror = () => {
      setConnected(false);
      es.close();
      // Reconnect after 5 seconds
      setTimeout(connect, 5000);
    };
  }, [url]);

  useEffect(() => {
    connect();
    return () => {
      eventSourceRef.current?.close();
    };
  }, [connect]);

  return { data, connected };
}

Dashboard Component

"use client";

import { useEventSource } from "@/hooks/use-event-source";

interface AnalyticsData {
  activeVisitors: number;
  pageViews24h: number;
  topPages: { path: string; views: number }[];
  viewsPerHour: { hour: string; views: number }[];
  referrers: { referrer: string; count: number }[];
  updatedAt: string;
}

export function RealtimeDashboard() {
  const { data, connected } = useEventSource<AnalyticsData>(
    "/api/analytics/stream"
  );

  if (!data) {
    return (
      <div className="animate-pulse space-y-6">
        <div className="grid grid-cols-2 gap-4">
          {[1, 2].map((i) => (
            <div key={i} className="h-24 bg-muted rounded-lg" />
          ))}
        </div>
        <div className="h-64 bg-muted rounded-lg" />
      </div>
    );
  }

  return (
    <div className="space-y-6">
      {/* Connection Status */}
      <div className="flex items-center gap-2 text-sm">
        <div
          className={`h-2 w-2 rounded-full ${
            connected ? "bg-green-500" : "bg-red-500"
          }`}
        />
        <span className="text-muted-foreground">
          {connected ? "Live" : "Reconnecting..."}
        </span>
        <span className="text-xs text-muted-foreground ml-auto">
          Updated {new Date(data.updatedAt).toLocaleTimeString()}
        </span>
      </div>

      {/* Metric Cards */}
      <div className="grid grid-cols-2 gap-4">
        <MetricCard
          label="Active Visitors"
          value={data.activeVisitors}
          live
        />
        <MetricCard
          label="Page Views (24h)"
          value={data.pageViews24h}
        />
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
        {/* Top Pages */}
        <div className="border rounded-lg p-4">
          <h3 className="font-semibold mb-3">Top Pages</h3>
          <div className="space-y-2">
            {data.topPages.map((page) => (
              <div key={page.path} className="flex items-center justify-between text-sm">
                <span className="truncate max-w-[200px]">{page.path}</span>
                <span className="text-muted-foreground font-mono">
                  {page.views.toLocaleString()}
                </span>
              </div>
            ))}
          </div>
        </div>

        {/* Top Referrers */}
        <div className="border rounded-lg p-4">
          <h3 className="font-semibold mb-3">Top Referrers</h3>
          <div className="space-y-2">
            {data.referrers.map((ref) => (
              <div key={ref.referrer} className="flex items-center justify-between text-sm">
                <span className="truncate max-w-[200px]">{ref.referrer}</span>
                <span className="text-muted-foreground font-mono">
                  {ref.count.toLocaleString()}
                </span>
              </div>
            ))}
          </div>
        </div>
      </div>

      {/* Views Over Time */}
      <div className="border rounded-lg p-4">
        <h3 className="font-semibold mb-3">Page Views (Last 24 Hours)</h3>
        <MiniBarChart data={data.viewsPerHour} />
      </div>
    </div>
  );
}

function MetricCard({
  label,
  value,
  live,
}: {
  label: string;
  value: number;
  live?: boolean;
}) {
  return (
    <div className="border rounded-lg p-4">
      <div className="flex items-center gap-2">
        <span className="text-sm text-muted-foreground">{label}</span>
        {live && (
          <span className="relative flex h-2 w-2">
            <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
            <span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
          </span>
        )}
      </div>
      <div className="text-3xl font-bold mt-1">{value.toLocaleString()}</div>
    </div>
  );
}

function MiniBarChart({ data }: { data: { hour: string; views: number }[] }) {
  const maxViews = Math.max(...data.map((d) => d.views), 1);

  return (
    <div className="flex items-end gap-1 h-32">
      {data.map((d) => (
        <div
          key={d.hour}
          className="flex-1 bg-primary/80 rounded-t min-h-[2px] hover:bg-primary transition-colors"
          style={{ height: `${(d.views / maxViews) * 100}%` }}
          title={`${new Date(d.hour).toLocaleTimeString()}: ${d.views} views`}
        />
      ))}
    </div>
  );
}

Track Page Views

// components/PageViewTracker.tsx
"use client";

import { useEffect } from "react";
import { usePathname } from "next/navigation";

export function PageViewTracker() {
  const pathname = usePathname();

  useEffect(() => {
    const sessionId = getOrCreateSessionId();

    fetch("/api/analytics/track", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        name: "pageview",
        path: pathname,
        referrer: document.referrer || undefined,
        sessionId,
      }),
    }).catch(() => {});
  }, [pathname]);

  return null;
}

function getOrCreateSessionId(): string {
  const key = "analytics_session";
  let id = sessionStorage.getItem(key);
  if (!id) {
    id = crypto.randomUUID();
    sessionStorage.setItem(key, id);
  }
  return id;
}

Need Custom Analytics?

We build privacy-friendly analytics dashboards with real-time tracking and custom events. Contact us to own your analytics data.

analyticsreal-timedashboardSSENext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles