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.