Build a Real-Time Dashboard with Next.js and WebSockets
Learn how to build a real-time dashboard with Next.js and WebSockets, featuring live data updates, reconnection logic, and interactive charts.
Tags
Build a Real-Time Dashboard with Next.js and WebSockets
In this tutorial, you will build a fully functional real-time dashboard that streams live data from a WebSocket server into a Next.js application. By the end, you will have a working system that pushes server-side metrics to the browser, renders them as interactive charts, and gracefully handles disconnections with automatic reconnection.
Real-time dashboards are essential for monitoring applications, financial data, IoT devices, and operational metrics. Traditional polling approaches waste bandwidth and introduce latency. WebSockets provide a persistent, bidirectional connection that delivers data the instant it is available.
TL;DR
Set up a standalone WebSocket server with the ws library, connect to it from a Next.js client component using the browser's native WebSocket API, implement exponential backoff reconnection, and render the incoming data stream with Recharts. The entire stack runs on TypeScript for end-to-end type safety.
Prerequisites
Before starting, make sure you have:
- ›Node.js 18+ installed
- ›A Next.js 14+ project with the App Router
- ›Basic familiarity with React hooks and TypeScript
- ›A terminal and code editor
Install the dependencies you will need:
npm install ws recharts
npm install -D @types/wsStep 1: Define the Data Types
Start by defining shared types that both your server and client will use. Create a types file at the root of your project.
// types/dashboard.ts
export interface MetricPayload {
timestamp: number;
cpu: number;
memory: number;
activeUsers: number;
requestsPerSecond: number;
}
export interface WebSocketMessage {
type: "metric" | "alert" | "heartbeat";
data: MetricPayload;
}Having a shared contract ensures that when the server sends data, the client knows exactly what shape to expect. This eliminates an entire class of runtime errors.
Step 2: Build the WebSocket Server
Create a standalone WebSocket server. This runs independently from your Next.js dev server because Next.js API routes are stateless and cannot maintain persistent connections.
// server/ws-server.ts
import { WebSocketServer, WebSocket } from "ws";
import type { MetricPayload, WebSocketMessage } from "../types/dashboard";
const PORT = 8080;
const wss = new WebSocketServer({ port: PORT });
const clients = new Set<WebSocket>();
function generateMetrics(): MetricPayload {
return {
timestamp: Date.now(),
cpu: Math.round(Math.random() * 40 + 30),
memory: Math.round(Math.random() * 20 + 55),
activeUsers: Math.floor(Math.random() * 200 + 100),
requestsPerSecond: Math.floor(Math.random() * 500 + 200),
};
}
function broadcast(message: WebSocketMessage): void {
const payload = JSON.stringify(message);
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}
wss.on("connection", (ws: WebSocket) => {
clients.add(ws);
console.log(`Client connected. Total: ${clients.size}`);
// Send initial data immediately so the dashboard is not empty
const initialMessage: WebSocketMessage = {
type: "metric",
data: generateMetrics(),
};
ws.send(JSON.stringify(initialMessage));
ws.on("close", () => {
clients.delete(ws);
console.log(`Client disconnected. Total: ${clients.size}`);
});
ws.on("error", (error) => {
console.error("WebSocket error:", error);
clients.delete(ws);
});
});
// Broadcast new metrics every 2 seconds
setInterval(() => {
const message: WebSocketMessage = {
type: "metric",
data: generateMetrics(),
};
broadcast(message);
}, 2000);
// Heartbeat every 30 seconds to keep connections alive
setInterval(() => {
const heartbeat: WebSocketMessage = {
type: "heartbeat",
data: generateMetrics(),
};
broadcast(heartbeat);
}, 30000);
console.log(`WebSocket server running on ws://localhost:${PORT}`);The server tracks all connected clients in a Set, broadcasts metrics every two seconds, and sends heartbeat messages to prevent proxy timeouts. Each metric includes CPU, memory usage, active user count, and requests per second.
Step 3: Create the WebSocket Hook with Reconnection Logic
The most important piece of the client-side code is a custom hook that manages the WebSocket lifecycle, including automatic reconnection with exponential backoff.
// hooks/useWebSocket.ts
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import type { WebSocketMessage } from "@/types/dashboard";
interface UseWebSocketOptions {
url: string;
maxRetries?: number;
baseDelay?: number;
}
interface UseWebSocketReturn {
isConnected: boolean;
lastMessage: WebSocketMessage | null;
connectionAttempt: number;
}
export function useWebSocket({
url,
maxRetries = 10,
baseDelay = 1000,
}: UseWebSocketOptions): UseWebSocketReturn {
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const [connectionAttempt, setConnectionAttempt] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const retriesRef = useRef(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
retriesRef.current = 0;
setConnectionAttempt(0);
console.log("WebSocket connected");
};
ws.onmessage = (event: MessageEvent) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
setLastMessage(message);
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onclose = () => {
setIsConnected(false);
wsRef.current = null;
if (retriesRef.current < maxRetries) {
// Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
const delay = Math.min(
baseDelay * Math.pow(2, retriesRef.current),
30000
);
retriesRef.current += 1;
setConnectionAttempt(retriesRef.current);
console.log(`Reconnecting in ${delay}ms (attempt ${retriesRef.current})`);
timeoutRef.current = setTimeout(connect, delay);
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
ws.close();
};
wsRef.current = ws;
}, [url, maxRetries, baseDelay]);
useEffect(() => {
connect();
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
wsRef.current?.close();
};
}, [connect]);
return { isConnected, lastMessage, connectionAttempt };
}The exponential backoff strategy is critical for production systems. Without it, a thousand clients would all try to reconnect simultaneously after a server restart, overwhelming the server. The backoff spreads reconnection attempts over time, allowing the server to recover gracefully.
Step 4: Build the Dashboard Data Manager
Create a component that accumulates incoming metrics into a time-series array suitable for charting.
// hooks/useDashboardData.ts
"use client";
import { useState, useEffect } from "react";
import type { MetricPayload, WebSocketMessage } from "@/types/dashboard";
const MAX_DATA_POINTS = 30;
export function useDashboardData(lastMessage: WebSocketMessage | null) {
const [metrics, setMetrics] = useState<MetricPayload[]>([]);
const [latestMetric, setLatestMetric] = useState<MetricPayload | null>(null);
useEffect(() => {
if (!lastMessage || lastMessage.type !== "metric") return;
const newMetric = lastMessage.data;
setLatestMetric(newMetric);
setMetrics((prev) => {
const updated = [...prev, newMetric];
// Keep only the most recent data points to prevent memory leaks
if (updated.length > MAX_DATA_POINTS) {
return updated.slice(updated.length - MAX_DATA_POINTS);
}
return updated;
});
}, [lastMessage]);
return { metrics, latestMetric };
}Capping the array at 30 data points prevents unbounded memory growth. In a production application, you might persist historical data to a database and only keep the recent window in memory.
Step 5: Create the Dashboard UI Components
Build the individual metric cards and chart components. These are client components because they depend on browser-only WebSocket data.
// components/dashboard/MetricCard.tsx
"use client";
interface MetricCardProps {
title: string;
value: string | number;
unit: string;
trend?: "up" | "down" | "stable";
}
export function MetricCard({ title, value, unit, trend }: MetricCardProps) {
const trendColor =
trend === "up"
? "text-green-400"
: trend === "down"
? "text-red-400"
: "text-gray-400";
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<p className="text-sm text-gray-400">{title}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-white">{value}</span>
<span className="text-sm text-gray-500">{unit}</span>
</div>
{trend && (
<span className={`mt-1 text-xs ${trendColor}`}>
{trend === "up" ? "Trending up" : trend === "down" ? "Trending down" : "Stable"}
</span>
)}
</div>
);
}Now create the chart component:
// components/dashboard/LiveChart.tsx
"use client";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import type { MetricPayload } from "@/types/dashboard";
interface LiveChartProps {
data: MetricPayload[];
title: string;
}
export function LiveChart({ data, title }: LiveChartProps) {
const formattedData = data.map((point) => ({
...point,
time: new Date(point.timestamp).toLocaleTimeString(),
}));
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">{title}</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={formattedData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="time" stroke="#9CA3AF" fontSize={12} />
<YAxis stroke="#9CA3AF" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: "#1F2937",
border: "1px solid #374151",
borderRadius: "8px",
}}
/>
<Legend />
<Line
type="monotone"
dataKey="cpu"
stroke="#3B82F6"
strokeWidth={2}
dot={false}
name="CPU %"
/>
<Line
type="monotone"
dataKey="memory"
stroke="#10B981"
strokeWidth={2}
dot={false}
name="Memory %"
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}Step 6: Assemble the Dashboard Page
Bring everything together in a Next.js page component.
// app/dashboard/page.tsx
"use client";
import { useWebSocket } from "@/hooks/useWebSocket";
import { useDashboardData } from "@/hooks/useDashboardData";
import { MetricCard } from "@/components/dashboard/MetricCard";
import { LiveChart } from "@/components/dashboard/LiveChart";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8080";
export default function DashboardPage() {
const { isConnected, lastMessage, connectionAttempt } = useWebSocket({
url: WS_URL,
});
const { metrics, latestMetric } = useDashboardData(lastMessage);
return (
<div className="min-h-screen bg-black p-8">
<div className="mx-auto max-w-7xl">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Live Dashboard</h1>
<div className="flex items-center gap-2">
<span
className={`h-3 w-3 rounded-full ${
isConnected ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-sm text-gray-400">
{isConnected
? "Connected"
: connectionAttempt > 0
? `Reconnecting (${connectionAttempt})...`
: "Disconnected"}
</span>
</div>
</div>
{/* Metric Cards */}
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
title="CPU Usage"
value={latestMetric?.cpu ?? "--"}
unit="%"
/>
<MetricCard
title="Memory Usage"
value={latestMetric?.memory ?? "--"}
unit="%"
/>
<MetricCard
title="Active Users"
value={latestMetric?.activeUsers ?? "--"}
unit="users"
/>
<MetricCard
title="Requests/sec"
value={latestMetric?.requestsPerSecond ?? "--"}
unit="req/s"
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<LiveChart data={metrics} title="System Metrics" />
</div>
</div>
</div>
);
}Step 7: Run the Application
You need two terminal processes running simultaneously: the WebSocket server and the Next.js development server.
# Terminal 1: Start the WebSocket server
npx tsx server/ws-server.ts
# Terminal 2: Start the Next.js dev server
npm run devAdd the WebSocket URL to your environment configuration:
# .env.local
NEXT_PUBLIC_WS_URL=ws://localhost:8080Navigate to http://localhost:3000/dashboard and you should see the metric cards updating every two seconds and the chart building a live time series.
Step 8: Add Connection Status Resilience
For production, add a visual indicator when the connection drops and data is stale. Modify the dashboard to show a banner:
// components/dashboard/ConnectionBanner.tsx
"use client";
interface ConnectionBannerProps {
isConnected: boolean;
attempt: number;
}
export function ConnectionBanner({ isConnected, attempt }: ConnectionBannerProps) {
if (isConnected) return null;
return (
<div className="mb-4 rounded-lg border border-yellow-600 bg-yellow-900/20 px-4 py-3">
<p className="text-sm text-yellow-400">
Connection lost. {attempt > 0 && `Reconnection attempt ${attempt}...`}
{" "}Data shown may be stale.
</p>
</div>
);
}This small addition makes a significant difference in user experience. Users need to know when the data they see is current versus outdated.
The Complete Architecture
Here is how all the pieces fit together:
- ›WebSocket Server (
server/ws-server.ts) generates and broadcasts metric data every 2 seconds - ›useWebSocket Hook manages the connection lifecycle with exponential backoff reconnection
- ›useDashboardData Hook accumulates metrics into a sliding window array
- ›MetricCard displays the latest snapshot values
- ›LiveChart renders the time-series data as an interactive line chart
- ›DashboardPage orchestrates everything as a client component
The separation of concerns here is deliberate. The WebSocket logic is isolated from the data management logic, which is isolated from the rendering logic. This makes each piece independently testable and replaceable.
Next Steps
Once you have the basic dashboard working, consider these enhancements:
- ›Add authentication to the WebSocket server using token-based handshake validation
- ›Implement rooms or channels so different dashboards subscribe to different data streams
- ›Store historical data in a time-series database like TimescaleDB for trend analysis
- ›Add Redis pub/sub for horizontal scaling across multiple WebSocket server instances
- ›Implement server-sent events as a fallback for environments where WebSockets are blocked
- ›Add alerting logic that triggers notifications when metrics exceed thresholds
FAQ
Can I use Socket.IO instead of ws for a Next.js real-time dashboard?
Yes, Socket.IO adds features like automatic reconnection, rooms, and namespaces out of the box. However, the ws library is lighter and gives you more control over the connection behavior. The reconnection logic shown in this guide achieves similar reliability with less overhead. Choose Socket.IO if you need features like binary streaming or room-based broadcasting without building them yourself.
How do I deploy a WebSocket server alongside Next.js on Vercel?
Vercel does not support persistent WebSocket connections because its serverless functions are stateless and short-lived. You need to deploy your WebSocket server separately on a platform like Railway, Fly.io, or AWS ECS, and point your NEXT_PUBLIC_WS_URL environment variable to that external server. Your Next.js frontend can still be hosted on Vercel.
How many concurrent WebSocket connections can a single Node.js server handle?
A well-tuned Node.js WebSocket server can handle tens of thousands of concurrent connections. The primary bottleneck is memory, since each connection consumes a small amount of RAM for its buffer and state. For larger workloads, use horizontal scaling with a Redis pub/sub layer to synchronize messages across multiple server instances.
Is WebSocket the only option for real-time features in Next.js?
No. Server-Sent Events (SSE) work well for one-way server-to-client streaming and are simpler to deploy because they use standard HTTP. For bidirectional communication like chat applications or collaborative editing, WebSockets remain the standard choice. You can also consider libraries like PartyKit or Liveblocks that abstract the real-time infrastructure entirely.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.