Blog/Tutorials & Step-by-Step/Build a Real-Time Dashboard with Next.js and WebSockets
POST
June 22, 2025
LAST UPDATEDJune 22, 2025

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

Next.jsWebSocketsReal-TimeDashboard
Build a Real-Time Dashboard with Next.js and WebSockets
6 min read

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:

bash
npm install ws recharts
npm install -D @types/ws

Step 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.

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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:

typescript
// 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.

typescript
// 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.

bash
# Terminal 1: Start the WebSocket server
npx tsx server/ws-server.ts
 
# Terminal 2: Start the Next.js dev server
npm run dev

Add the WebSocket URL to your environment configuration:

bash
# .env.local
NEXT_PUBLIC_WS_URL=ws://localhost:8080

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

typescript
// 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:

  1. WebSocket Server (server/ws-server.ts) generates and broadcasts metric data every 2 seconds
  2. useWebSocket Hook manages the connection lifecycle with exponential backoff reconnection
  3. useDashboardData Hook accumulates metrics into a sliding window array
  4. MetricCard displays the latest snapshot values
  5. LiveChart renders the time-series data as an interactive line chart
  6. 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.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

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
Mar 21, 20266 min read
Next.js
Node.js
BFF

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
Mar 21, 20265 min read
CI/CD
Next.js
Docker

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.