Building a Real-Time Sensor Dashboard with Next.js and MQTT
WebSockets, server-sent events, or polling? For industrial sensor data, the answer depends on your update frequency and failure tolerance. Here's the full stack.
A temperature sensor on the factory floor updates once per second. That value travels over MQTT, passes through a broker, and must appear on a web dashboard viewed by an operations manager three time zones away. The path from the sensor to the browser involves several architectural decisions, each with consequences for latency, reliability, and scalability.
This article examines the complete stack required to build a real-time sensor dashboard using Next.js and MQTT. It evaluates the tradeoffs between polling, Server-Sent Events, and WebSockets, then implements a production-ready solution.
The Data Pipeline
The journey of a sensor reading follows a predictable sequence. An edge device or PLC gateway publishes a message to an MQTT broker. A Next.js application must receive that message and forward it to connected web clients. The challenge lies in the bridge between the MQTT broker and the browser.
The Next.js application can run in two distinct environments: the Node.js server and the client browser. MQTT clients cannot run natively in the browser without exposing broker credentials and relying on WebSocket transports. The recommended architecture places the MQTT client on the server side, where credentials remain protected and the connection to the broker remains stable.
Comparing Data Delivery Strategies
Three mechanisms exist to move data from the server to the browser. The correct choice depends on the characteristics of the industrial data being visualized.
Strategy | Direction | Connection | Best For |
|---|---|---|---|
Polling | Client requests | Short-lived | Slow-changing values, low update frequency |
Server-Sent Events | Server pushes | Persistent, unidirectional | Moderate frequency, one-way data flow |
WebSockets | Bidirectional | Persistent, full-duplex | High frequency, interactive control |
Polling is the simplest to implement and the most resilient to network interruptions. The client issues an HTTP request every few seconds. If a request fails, the next one will eventually succeed. For sensor values that change slowly, such as ambient temperature or tank levels, a five-second poll interval is imperceptible to the user and places negligible load on the server.
Server-Sent Events (SSE) provide a persistent, server-to-client stream over standard HTTP. The client opens a connection and the server pushes messages as they arrive from MQTT. SSE automatically handles reconnection if the stream drops. The protocol is unidirectional, which matches the use case for a dashboard that only displays data. SSE is lighter than WebSockets and works reliably through most proxies and firewalls.
WebSockets offer full-duplex communication, allowing the client to send commands as well as receive data. This is necessary for applications that allow an operator to change setpoints or acknowledge alarms from the dashboard. However, WebSockets require a dedicated connection per client and more sophisticated state management on the server. For a read-only dashboard, the additional complexity is rarely justified.
Implementing the MQTT to SSE Bridge in Next.js
The App Router in Next.js 13 and later supports Route Handlers, which can maintain long-lived connections suitable for SSE. The following implementation creates an endpoint that subscribes to MQTT topics and forwards incoming messages to connected clients.
First, establish the MQTT client on the server. The client connects once when the application starts and remains connected indefinitely.
// lib/mqtt.ts
import mqtt from 'mqtt';
const brokerUrl = process.env.MQTT_BROKER_URL || 'mqtt://localhost:1883';
const client = mqtt.connect(brokerUrl);
client.on('connect', () => {
console.log('MQTT client connected');
client.subscribe('plant/+/sensor/+');
});
export { client };
Next, create a Route Handler that exposes an SSE stream. The handler subscribes to MQTT message events and writes them to the response stream in the SSE format.
// app/api/sensor-stream/route.ts
import { NextRequest } from 'next/server';
import { client } from '@/lib/mqtt';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const sendEvent = (topic: string, message: Buffer) => {
const data = `event: message\ndata: ${JSON.stringify({
topic,
value: message.toString(),
timestamp: Date.now(),
})}\n\n`;
controller.enqueue(encoder.encode(data));
};
client.on('message', sendEvent);
request.signal.addEventListener('abort', () => {
client.removeListener('message', sendEvent);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
This endpoint establishes a single SSE connection per browser client. When the MQTT client receives a message on any subscribed topic, it forwards the payload to every connected SSE stream. The browser receives the data as discrete events.
Consuming the Stream in a React Component
The frontend uses the native `EventSource` API to connect to the SSE endpoint. The component manages connection state and updates the displayed sensor values in real time.
// components/SensorDashboard.tsx
'use client';
import { useEffect, useState } from 'react';
interface SensorReading {
topic: string;
value: string;
timestamp: number;
}
export default function SensorDashboard() {
const [readings, setReadings] = useState<Map<string, SensorReading>>(new Map());
const [connected, setConnected] = useState(false);
useEffect(() => {
const eventSource = new EventSource('/api/sensor-stream');
eventSource.onopen = () => setConnected(true);
eventSource.onerror = () => setConnected(false);
eventSource.addEventListener('message', (event) => {
const data: SensorReading = JSON.parse(event.data);
setReadings((prev) => new Map(prev).set(data.topic, data));
});
return () => {
eventSource.close();
};
}, []);
return (
<div>
<div>Connection: {connected ? 'Live' : 'Disconnected'}</div>
<table>
<thead>
<tr>
<th>Topic</th>
<th>Value</th>
<th>Last Update</th>
</tr>
</thead>
<tbody>
{Array.from(readings.values()).map((reading) => (
<tr key={reading.topic}>
<td>{reading.topic}</td>
<td>{reading.value}</td>
<td>{new Date(reading.timestamp).toLocaleTimeString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Handling Failure and Reconnection
Industrial networks are unreliable by nature. The dashboard must degrade gracefully and recover automatically when connectivity is restored.
The browser's `EventSource` implementation includes automatic reconnection logic. If the connection drops, the client attempts to reconnect after a delay specified by the server or a default of three seconds. The server can control this behavior by including a `retry:` field in the SSE stream.
On the server side, the MQTT client should be configured to handle disconnections and resubscribe to topics upon reconnection. The `mqtt` library provides automatic reconnect options.
const client = mqtt.connect(brokerUrl, {
reconnectPeriod: 5000,
connectTimeout: 10000,
});
client.on('reconnect', () => {
console.log('MQTT client reconnecting');
});
For applications that require guaranteed delivery of every data point, consider integrating a lightweight message queue or using MQTT QoS level 1 or 2. The SSE stream itself does not buffer messages during disconnections, so a brief network interruption will result in a gap in the displayed data. This is acceptable for most monitoring dashboards, where the most recent value is more important than a complete historical record.
Scaling Beyond a Single Server Instance
The architecture described assumes a single Next.js server instance. Each connected browser maintains its own SSE connection to that server. If the application scales horizontally across multiple server instances, the MQTT messages must reach all instances.
Two patterns address this challenge. The first uses a shared message bus, such as Redis Pub/Sub, to distribute MQTT messages to all server instances. The second moves the SSE endpoint to a dedicated real-time service that runs independently of the Next.js application.
For most industrial dashboards with dozens of concurrent users, a single server instance is sufficient. The limiting factor is rarely the number of SSE connections, which can scale to thousands per Node.js process, but rather the volume of MQTT messages being processed.
When to Choose Each Approach
Use Case | Recommended Strategy |
|---|---|
Slow-changing values (tank levels, ambient conditions) | Polling every 5-30 seconds |
Real-time monitoring without user input | Server-Sent Events |
Bidirectional control or sub-second latency | WebSockets |
For a sensor dashboard displaying temperature, pressure, and machine state, Server-Sent Events provide the optimal balance of simplicity, reliability, and real-time performance. The implementation requires less than one hundred lines of code and leverages built-in browser capabilities without additional dependencies.
The combination of MQTT for industrial data ingestion and SSE for browser delivery creates a clean separation of concerns. The MQTT broker handles the unreliable, bandwidth-constrained world of field devices. The SSE stream provides a standard HTTP interface that any web application can consume. Together, they form a robust pipeline from the sensor to the screen.
Discussion
Sign in to join the discussion.
Sign in →No comments yet. Be the first.
