ESP32 + MQTT + Next.js — Building a Real-Time Sensor Dashboard from Scratch
A step-by-step guide to connecting an ESP32 microcontroller to a Next.js dashboard via MQTT, with real-time charts and alerts. Hardware meets modern web dev.
I have a problem with temperature sensors. Not the sensors themselves — they work fine. The problem is that every off-the-shelf IoT dashboard either costs a fortune, looks like it was designed in 2009, or locks you into some vendor’s cloud platform where your data disappears the moment you stop paying.
So I built my own. An ESP32 reads temperature and humidity from a DHT22 sensor, publishes data over MQTT, and a Next.js app picks it up in real time via Server-Sent Events. The whole thing runs on a $4 microcontroller and a free-tier hosting stack.
Here’s exactly how I did it, including every config file and the mistakes I made along the way.
What We’re Building
The architecture is straightforward:
- ESP32 reads sensor data every 5 seconds
- MQTT broker (hosted on Railway) receives the messages
- Next.js API route subscribes to the MQTT topic
- Server-Sent Events (SSE) push data to the browser in real time
- React frontend renders live charts with Recharts
No polling. No Firebase. No third-party IoT platform. Just standard protocols and tools you already know.
Part 1: The Hardware Setup
You need exactly three things:
- ESP32 dev board (I use the ESP32-WROOM-32, about $4 on AliExpress)
- DHT22 temperature/humidity sensor ($2-3)
- Three jumper wires
Wire the DHT22 to the ESP32:
DHT22 Pin 1 (VCC) → ESP32 3.3V
DHT22 Pin 2 (Data) → ESP32 GPIO 4
DHT22 Pin 4 (GND) → ESP32 GND
That’s it. No resistors needed — the ESP32’s internal pull-up handles the data line just fine with the DHT22 at short cable lengths (under 1 meter).
Part 2: ESP32 Firmware
I use the Arduino IDE with the ESP32 board package. If you haven’t set it up, add https://espressif.github.io/arduino-esp32/package_esp32_index.json to your board manager URLs.
Install these libraries via Library Manager:
- DHT sensor library by Adafruit
- PubSubClient by Nick O’Leary
Here’s the full firmware:
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#define DHTPIN 4
#define DHTTYPE DHT22
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* mqtt_server = "YOUR_MQTT_BROKER_HOST";
const int mqtt_port = 1883;
const char* mqtt_user = "sensor";
const char* mqtt_pass = "YOUR_MQTT_PASSWORD";
const char* device_id = "esp32-office-01";
DHT dht(DHTPIN, DHTTYPE);
WiFiClient espClient;
PubSubClient client(espClient);
unsigned long lastMsg = 0;
const long interval = 5000;
void setup_wifi() {
delay(10);
Serial.printf("Connecting to %s", ssid);
WiFi.begin(ssid, password);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\nConnected. IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("\nWiFi connection failed. Restarting...");
ESP.restart();
}
}
void reconnect() {
int retries = 0;
while (!client.connected() && retries < 5) {
Serial.print("MQTT connecting...");
if (client.connect(device_id, mqtt_user, mqtt_pass)) {
Serial.println("connected");
} else {
Serial.printf("failed, rc=%d. Retrying in 2s\n", client.state());
delay(2000);
retries++;
}
}
}
void setup() {
Serial.begin(115200);
dht.begin();
setup_wifi();
client.setServer(mqtt_server, mqtt_port);
}
void loop() {
if (!client.connected()) {
reconnect();
}
client.loop();
unsigned long now = millis();
if (now - lastMsg > interval) {
lastMsg = now;
float temp = dht.readTemperature();
float humidity = dht.readHumidity();
if (isnan(temp) || isnan(humidity)) {
Serial.println("DHT read failed, skipping");
return;
}
char payload[128];
snprintf(payload, sizeof(payload),
"{\"device\":\"%s\",\"temp\":%.1f,\"humidity\":%.1f,\"ts\":%lu}",
device_id, temp, humidity, now
);
client.publish("sensors/office/climate", payload);
Serial.printf("Published: %s\n", payload);
}
}
A few things worth noting here. The reconnection logic has a retry limit — without it, a broker outage will lock up your ESP32 in an infinite loop. I also use snprintf instead of Arduino’s String class because dynamic string allocation on the ESP32 leads to heap fragmentation over time. I’ve had boards crash after 3-4 days of uptime because of careless string handling.
Part 3: The MQTT Broker
You need an MQTT broker that’s accessible from both your ESP32 (on your local network or the internet) and your Next.js server.
I run Mosquitto on Railway because it takes about 90 seconds to deploy and costs under $2/month for this kind of lightweight usage.
Create a Dockerfile for the broker:
FROM eclipse-mosquitto:2
COPY mosquitto.conf /mosquitto/config/mosquitto.conf
COPY password_file /mosquitto/config/password_file
EXPOSE 1883
CMD ["mosquitto", "-c", "/mosquitto/config/mosquitto.conf"]
The mosquitto.conf:
listener 1883
allow_anonymous false
password_file /mosquitto/config/password_file
max_connections 50
max_inflight_messages 20
log_dest stdout
log_type error
log_type warning
Generate the password file locally:
brew install mosquitto # macOS
# or: apt-get install mosquitto # Linux
mosquitto_passwd -c password_file sensor
Push this to a GitHub repo, connect it to Railway, and you have a production MQTT broker in under two minutes. Set the PORT variable to 1883 in Railway’s settings, and note the public hostname it generates — you’ll need it for both the ESP32 firmware and the Next.js app.
Alternative: If you prefer managed services, HiveMQ Cloud has a free tier with 100 connections. That’s more than enough for a home setup.
Part 4: Next.js Backend — MQTT to SSE Bridge
This is where it gets interesting. The Next.js server subscribes to the MQTT broker and re-broadcasts messages to the browser using Server-Sent Events. This avoids the need for a WebSocket library and works with any hosting that supports streaming responses.
First, install the MQTT client:
npm install mqtt
Create a shared MQTT client that persists across hot reloads in development:
// lib/mqtt-client.ts
import mqtt, { MqttClient } from "mqtt";
declare global {
var __mqttClient: MqttClient | undefined;
}
type MessageHandler = (topic: string, payload: string) => void;
const listeners = new Set<MessageHandler>();
function getClient(): MqttClient {
if (global.__mqttClient?.connected) {
return global.__mqttClient;
}
const client = mqtt.connect(process.env.MQTT_BROKER_URL!, {
username: process.env.MQTT_USERNAME,
password: process.env.MQTT_PASSWORD,
reconnectPeriod: 5000,
connectTimeout: 10000,
});
client.on("connect", () => {
console.log("MQTT connected");
client.subscribe("sensors/#", { qos: 1 });
});
client.on("message", (topic, message) => {
const payload = message.toString();
listeners.forEach((handler) => handler(topic, payload));
});
client.on("error", (err) => {
console.error("MQTT error:", err.message);
});
global.__mqttClient = client;
return client;
}
export function subscribe(handler: MessageHandler): () => void {
getClient();
listeners.add(handler);
return () => listeners.delete(handler);
}
Now the SSE endpoint:
// app/api/sensors/stream/route.ts
import { subscribe } from "@/lib/mqtt-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const unsubscribe = subscribe((topic, payload) => {
const event = `data: ${JSON.stringify({ topic, ...JSON.parse(payload) })}\n\n`;
controller.enqueue(encoder.encode(event));
});
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(": heartbeat\n\n"));
}, 30000);
const cleanup = () => {
unsubscribe();
clearInterval(heartbeat);
};
(controller as any).__cleanup = cleanup;
},
cancel(controller) {
(controller as any).__cleanup?.();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
The heartbeat comment line (: heartbeat) is crucial. Without it, proxies and load balancers (including Vercel’s) will kill the connection after 30-60 seconds of inactivity. I spent an embarrassing amount of time debugging this before realizing the connection was being terminated by the infrastructure, not my code.
Part 5: The Dashboard Frontend
For the chart, I use Recharts because it handles streaming data well and doesn’t require a PhD to configure.
npm install recharts
// app/dashboard/page.tsx
"use client";
import { useEffect, useState } from "react";
import {
LineChart, Line, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from "recharts";
type Reading = {
device: string;
temp: number;
humidity: number;
time: string;
};
const MAX_POINTS = 60;
export default function Dashboard() {
const [readings, setReadings] = useState<Reading[]>([]);
const [connected, setConnected] = useState(false);
const [lastReading, setLastReading] = useState<Reading | null>(null);
useEffect(() => {
const es = new EventSource("/api/sensors/stream");
es.onopen = () => setConnected(true);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const reading: Reading = {
device: data.device,
temp: data.temp,
humidity: data.humidity,
time: new Date().toLocaleTimeString("en-GB", {
hour: "2-digit", minute: "2-digit", second: "2-digit",
}),
};
setLastReading(reading);
setReadings((prev) => {
const next = [...prev, reading];
return next.length > MAX_POINTS ? next.slice(-MAX_POINTS) : next;
});
} catch {}
};
es.onerror = () => setConnected(false);
return () => es.close();
}, []);
return (
<div style={{ maxWidth: 960, margin: "0 auto", padding: "2rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h1>Sensor Dashboard</h1>
<span style={{
display: "inline-block", width: 12, height: 12, borderRadius: "50%",
backgroundColor: connected ? "#22c55e" : "#ef4444",
}} />
</div>
{lastReading && (
<div style={{ display: "flex", gap: "2rem", margin: "1rem 0" }}>
<div>
<span style={{ fontSize: "2rem", fontWeight: 700 }}>
{lastReading.temp.toFixed(1)}°C
</span>
<p style={{ color: "#888" }}>Temperature</p>
</div>
<div>
<span style={{ fontSize: "2rem", fontWeight: 700 }}>
{lastReading.humidity.toFixed(1)}%
</span>
<p style={{ color: "#888" }}>Humidity</p>
</div>
</div>
)}
<ResponsiveContainer width="100%" height={400}>
<LineChart data={readings}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" stroke="#888" tick={{ fontSize: 12 }} />
<YAxis yAxisId="temp" domain={[15, 35]} stroke="#f97316" />
<YAxis yAxisId="humidity" orientation="right" domain={[20, 80]} stroke="#3b82f6" />
<Tooltip />
<Legend />
<Line yAxisId="temp" type="monotone" dataKey="temp"
stroke="#f97316" name="Temperature (°C)" dot={false} strokeWidth={2} />
<Line yAxisId="humidity" type="monotone" dataKey="humidity"
stroke="#3b82f6" name="Humidity (%)" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
The MAX_POINTS constant keeps the chart from getting unusably dense. 60 points at 5-second intervals gives you the last 5 minutes of data. For longer history, you’d want to persist readings to a database — Supabase works well here since its Postgres instance can handle time-series data with a simple INSERT and the free tier gives you 500MB of storage.
Part 6: Environment Variables and Deployment
Your .env.local for Next.js:
MQTT_BROKER_URL=mqtt://your-broker.railway.app:1883
MQTT_USERNAME=sensor
MQTT_PASSWORD=your_password_here
For deployment, I host the Next.js app on Vercel. The SSE endpoint works on Vercel’s serverless functions with the Node.js runtime — just make sure you have export const runtime = "nodejs" in your route file. The Edge runtime doesn’t support the mqtt npm package because it relies on Node.js net module.
One gotcha: Vercel’s serverless functions have a 30-second timeout on the Hobby plan. Your SSE connection will get cut off after 30 seconds and the browser will automatically reconnect via EventSource. This creates a brief flicker in the connection indicator. On the Pro plan, you get up to 300 seconds. For a home project, the 30-second reconnect cycle is honestly fine.
If you want a persistent connection without reconnect cycles, consider hosting the Next.js app on Railway instead — Railway runs long-lived processes, so your SSE stream stays open indefinitely.
Part 7: Adding Alerts
A dashboard without alerts is just a screensaver. Here’s a minimal alert system using a simple threshold check in the MQTT subscription:
// lib/alerts.ts
type AlertConfig = {
metric: "temp" | "humidity";
threshold: number;
direction: "above" | "below";
message: string;
};
const ALERTS: AlertConfig[] = [
{ metric: "temp", threshold: 30, direction: "above", message: "Temperature exceeds 30°C" },
{ metric: "temp", threshold: 16, direction: "below", message: "Temperature dropped below 16°C" },
{ metric: "humidity", threshold: 70, direction: "above", message: "High humidity warning" },
];
let lastAlertTime = 0;
const COOLDOWN_MS = 5 * 60 * 1000;
export function checkAlerts(data: { temp: number; humidity: number }): string | null {
const now = Date.now();
if (now - lastAlertTime < COOLDOWN_MS) return null;
for (const alert of ALERTS) {
const value = data[alert.metric];
const triggered =
alert.direction === "above" ? value > alert.threshold : value < alert.threshold;
if (triggered) {
lastAlertTime = now;
return alert.message;
}
}
return null;
}
You can pipe these alerts into a webhook — I send mine to a Discord channel via a simple fetch call. It’s crude, but I get a phone notification within seconds when my office overheats.
What This Actually Costs
Here’s the monthly bill for this setup:
| Component | Service | Cost | |-----------|---------|------| | MQTT Broker | Railway | ~$1-2 | | Next.js App | Vercel Free | $0 | | ESP32 + DHT22 | One-time purchase | ~$6 | | Total recurring | | ~$2/month |
Compare that to Adafruit IO at $10/month or AWS IoT Core which charges per million messages. For a home sensor network with 5-10 devices, the self-hosted approach is nearly free.
Where to Go from Here
This basic setup handles a single sensor, but the architecture scales. I’m currently running six ESP32 boards around my apartment — office, bedroom, balcony, server closet, bathroom, and kitchen. Each publishes to a different MQTT topic, and the dashboard shows them all on a single chart with device-based filtering.
Some ideas if you want to extend this:
Persistent storage: Add a Supabase insert in the MQTT handler to keep historical data. A simple cron job can aggregate hourly averages so you’re not storing raw 5-second readings forever.
Multiple sensor types: The same MQTT pattern works for anything — soil moisture, light levels, CO2 sensors, power consumption. Just change the topic and payload structure.
OTA updates: The ESP32 supports over-the-air firmware updates. Once you have more than three boards, walking around with a USB cable gets old fast. The ArduinoOTA library handles this with about 20 lines of code.
Mobile alerts: Replace the Discord webhook with ntfy.sh for push notifications on your phone without any app development.
The full source code for the dashboard is on my GitHub if you want to skip the copy-paste. The ESP32 firmware is in the /hardware directory.
Some links in this article are affiliate links. If you sign up for a service through one of these links, I may earn a small commission at no extra cost to you. I only recommend tools I actually use in my own projects.