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

How to Build a Performance Monitoring Dashboard in Next.js

Build a Core Web Vitals monitoring dashboard that tracks LCP, FID, CLS, INP, and TTFB with historical trends in Next.js.

Ryel Banfield

Founder & Lead Developer

Monitoring Core Web Vitals helps you maintain a fast, user-friendly site. Here is how to collect and visualize real user metrics.

Collect Web Vitals

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

import { useReportWebVitals } from "next/web-vitals";

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    const body = {
      name: metric.name,
      value: metric.value,
      rating: metric.rating, // "good" | "needs-improvement" | "poor"
      id: metric.id,
      navigationType: metric.navigationType,
      url: window.location.pathname,
      connection: getConnectionType(),
      deviceType: getDeviceType(),
      timestamp: Date.now(),
    };

    // Use sendBeacon for reliable delivery
    if (navigator.sendBeacon) {
      navigator.sendBeacon("/api/vitals", JSON.stringify(body));
    } else {
      fetch("/api/vitals", {
        method: "POST",
        body: JSON.stringify(body),
        keepalive: true,
      }).catch(() => {});
    }
  });

  return null;
}

function getConnectionType(): string {
  const nav = navigator as Navigator & {
    connection?: { effectiveType?: string };
  };
  return nav.connection?.effectiveType ?? "unknown";
}

function getDeviceType(): string {
  const width = window.innerWidth;
  if (width < 768) return "mobile";
  if (width < 1024) return "tablet";
  return "desktop";
}

API Route to Store Vitals

// app/api/vitals/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { webVitals } from "@/db/schema";
import { z } from "zod";

const VitalSchema = z.object({
  name: z.enum(["LCP", "FID", "CLS", "INP", "TTFB", "FCP"]),
  value: z.number(),
  rating: z.enum(["good", "needs-improvement", "poor"]),
  id: z.string(),
  navigationType: z.string().optional(),
  url: z.string(),
  connection: z.string(),
  deviceType: z.string(),
  timestamp: z.number(),
});

export async function POST(request: NextRequest) {
  const body = await request.json();
  const parsed = VitalSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid data" }, { status: 400 });
  }

  await db.insert(webVitals).values({
    ...parsed.data,
    createdAt: new Date(parsed.data.timestamp),
  });

  return new NextResponse(null, { status: 204 });
}

Dashboard API

// app/api/vitals/dashboard/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";

export async function GET(request: NextRequest) {
  const days = Number(request.nextUrl.searchParams.get("days") ?? "7");
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);

  // P75 values for each metric
  const p75Values = await db.execute(
    `SELECT
      name,
      PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75,
      PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY value) as p50,
      COUNT(*) as sample_count,
      AVG(value) as mean
    FROM web_vitals
    WHERE created_at > $1
    GROUP BY name`,
    [since]
  );

  // Rating distribution
  const ratings = await db.execute(
    `SELECT
      name,
      rating,
      COUNT(*) as count
    FROM web_vitals
    WHERE created_at > $1
    GROUP BY name, rating
    ORDER BY name, rating`,
    [since]
  );

  // Daily trends
  const trends = await db.execute(
    `SELECT
      name,
      DATE(created_at) as date,
      PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75
    FROM web_vitals
    WHERE created_at > $1
    GROUP BY name, DATE(created_at)
    ORDER BY date`,
    [since]
  );

  // Performance by page
  const pages = await db.execute(
    `SELECT
      url,
      AVG(CASE WHEN name = 'LCP' THEN value END) as lcp,
      AVG(CASE WHEN name = 'CLS' THEN value END) as cls,
      AVG(CASE WHEN name = 'INP' THEN value END) as inp,
      COUNT(DISTINCT id) as sessions
    FROM web_vitals
    WHERE created_at > $1
    GROUP BY url
    ORDER BY sessions DESC
    LIMIT 20`,
    [since]
  );

  return NextResponse.json({
    p75: p75Values.rows,
    ratings: ratings.rows,
    trends: trends.rows,
    pages: pages.rows,
  });
}

Dashboard UI

// app/(admin)/admin/performance/page.tsx
import { PerformanceDashboard } from "@/components/admin/PerformanceDashboard";

export default function PerformancePage() {
  return (
    <div className="max-w-6xl mx-auto py-8 px-4">
      <h1 className="text-2xl font-bold mb-6">Performance Monitoring</h1>
      <PerformanceDashboard />
    </div>
  );
}
"use client";

import { useEffect, useState } from "react";

interface VitalMetric {
  name: string;
  p75: number;
  p50: number;
  sample_count: number;
  mean: number;
}

interface DashboardData {
  p75: VitalMetric[];
  ratings: { name: string; rating: string; count: number }[];
  trends: { name: string; date: string; p75: number }[];
  pages: { url: string; lcp: number; cls: number; inp: number; sessions: number }[];
}

const THRESHOLDS = {
  LCP: { good: 2500, poor: 4000, unit: "ms", label: "Largest Contentful Paint" },
  FID: { good: 100, poor: 300, unit: "ms", label: "First Input Delay" },
  CLS: { good: 0.1, poor: 0.25, unit: "", label: "Cumulative Layout Shift" },
  INP: { good: 200, poor: 500, unit: "ms", label: "Interaction to Next Paint" },
  TTFB: { good: 800, poor: 1800, unit: "ms", label: "Time to First Byte" },
  FCP: { good: 1800, poor: 3000, unit: "ms", label: "First Contentful Paint" },
};

export function PerformanceDashboard() {
  const [data, setData] = useState<DashboardData | null>(null);
  const [days, setDays] = useState(7);

  useEffect(() => {
    fetch(`/api/vitals/dashboard?days=${days}`)
      .then((res) => res.json())
      .then(setData);
  }, [days]);

  if (!data) {
    return (
      <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="h-32 bg-muted rounded-lg animate-pulse" />
        ))}
      </div>
    );
  }

  return (
    <div className="space-y-8">
      {/* Time Range */}
      <div className="flex gap-2">
        {[1, 7, 14, 30].map((d) => (
          <button
            key={d}
            onClick={() => setDays(d)}
            className={`px-3 py-1.5 text-sm rounded-md ${
              days === d ? "bg-primary text-primary-foreground" : "border hover:bg-muted"
            }`}
          >
            {d === 1 ? "24h" : `${d}d`}
          </button>
        ))}
      </div>

      {/* Metric Cards */}
      <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
        {data.p75.map((metric) => {
          const threshold = THRESHOLDS[metric.name as keyof typeof THRESHOLDS];
          if (!threshold) return null;

          const rating =
            metric.p75 <= threshold.good
              ? "good"
              : metric.p75 <= threshold.poor
                ? "needs-improvement"
                : "poor";

          const ratingColors = {
            good: "text-green-600 bg-green-50 border-green-200",
            "needs-improvement": "text-yellow-600 bg-yellow-50 border-yellow-200",
            poor: "text-red-600 bg-red-50 border-red-200",
          };

          return (
            <div
              key={metric.name}
              className={`p-4 rounded-lg border ${ratingColors[rating]}`}
            >
              <div className="flex items-center justify-between mb-2">
                <span className="text-xs font-medium uppercase opacity-70">
                  {metric.name}
                </span>
                <span className="text-xs opacity-60">
                  {metric.sample_count.toLocaleString()} samples
                </span>
              </div>
              <div className="text-2xl font-bold">
                {metric.name === "CLS"
                  ? metric.p75.toFixed(3)
                  : `${Math.round(metric.p75)}${threshold.unit}`}
              </div>
              <div className="text-xs opacity-70 mt-1">
                {threshold.label} (p75)
              </div>
              <div className="text-xs opacity-60 mt-0.5">
                Median: {metric.name === "CLS" ? metric.p50.toFixed(3) : `${Math.round(metric.p50)}${threshold.unit}`}
              </div>
            </div>
          );
        })}
      </div>

      {/* Page Performance Table */}
      <div className="border rounded-lg overflow-hidden">
        <div className="px-4 py-3 border-b bg-muted/50">
          <h2 className="font-semibold">Performance by Page</h2>
        </div>
        <div className="overflow-x-auto">
          <table className="w-full text-sm">
            <thead>
              <tr className="border-b">
                <th className="text-left px-4 py-2">Page</th>
                <th className="text-right px-4 py-2">LCP</th>
                <th className="text-right px-4 py-2">CLS</th>
                <th className="text-right px-4 py-2">INP</th>
                <th className="text-right px-4 py-2">Sessions</th>
              </tr>
            </thead>
            <tbody>
              {data.pages.map((page) => (
                <tr key={page.url} className="border-b last:border-0">
                  <td className="px-4 py-2 font-mono text-xs truncate max-w-[200px]">
                    {page.url}
                  </td>
                  <td className="px-4 py-2 text-right">
                    <MetricValue value={page.lcp} threshold={THRESHOLDS.LCP} />
                  </td>
                  <td className="px-4 py-2 text-right">
                    <MetricValue value={page.cls} threshold={THRESHOLDS.CLS} isCLS />
                  </td>
                  <td className="px-4 py-2 text-right">
                    <MetricValue value={page.inp} threshold={THRESHOLDS.INP} />
                  </td>
                  <td className="px-4 py-2 text-right text-muted-foreground">
                    {page.sessions.toLocaleString()}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

function MetricValue({
  value,
  threshold,
  isCLS,
}: {
  value: number | null;
  threshold: { good: number; poor: number; unit: string };
  isCLS?: boolean;
}) {
  if (value === null) return <span className="text-muted-foreground">-</span>;

  const rating =
    value <= threshold.good
      ? "text-green-600"
      : value <= threshold.poor
        ? "text-yellow-600"
        : "text-red-600";

  return (
    <span className={`font-mono text-xs ${rating}`}>
      {isCLS ? value.toFixed(3) : `${Math.round(value)}${threshold.unit}`}
    </span>
  );
}

Need Performance Optimization?

We build fast websites and monitor Core Web Vitals for ongoing performance. Contact us to improve your site speed.

performanceCore Web VitalsmonitoringNext.jstutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles