IoT

Build an ESP32 Smart Home Dashboard with Node.js

Tech Setup4 min read
TS

Tech Setup

Reviewed July 30, 2026

Build an ESP32 Smart Home Dashboard with Node.js

Building a low-latency, extensible smart home dashboard doesn’t require heavy enterprise stacks or bloated cloud ecosystems. For Tier-1 developers who prefer bare-metal control and streamlined tooling, pairing a low-cost ESP32 microcontroller with a lightweight Node.js backend provides an ideal architecture.

In this guide, we will build a complete, real-time smart home telemetry and control dashboard running on Windows. We will configure an ESP32 to gather environmental sensor data and expose actuator controls, interface it with a Node.js server using WebSockets for sub-millisecond updates, and render a responsive local dashboard.

Prerequisites and Architecture

Before writing code, let's establish our local development environment on Windows and define the communication flow.

System Requirements

  • Operating System: Windows 10/11 (WSL2 optional, but native Windows PowerShell/CMD works seamlessly for serial flashing).
  • Node.js: v18.x LTS or higher installed globally.
  • IDE: Visual Studio Code with the following extensions:
    • PlatformIO IDE (recommended for ESP32 compilation and flashing over the Arduino IDE).
    • C/C++ (Microsoft).

The Communication Pipeline

To keep the system modular and fast, we use a publish-subscribe pattern over WebSockets rather than polling HTTP endpoints:

  1. Sensors (ESP32): Read data from peripherals (e.g., DHT22 temperature/humidity) and push updates over Wi-Fi.
  2. Server (Node.js/Express/ws): Acts as the central WebSocket broker and REST API gateway. It maintains state in memory (or a lightweight SQLite instance) and broadcasts state changes to all connected clients.
  3. Client (HTML5/Vanilla JS): A single-page dashboard rendering real-time telemetry charts and toggles.
+--------------------+      HTTP/WS      +--------------------+      WebSocket      +-----------------+
|                    | <---------------> |                    | <-----------------> |                 |
|   ESP32 Firmware   |                   |   Node.js Server   |                     | Client Browser  |
| (Sensors/Actuators)|                   |  (Express + ws)    |                     |   (Dashboard)   |
+--------------------+                   +--------------------+                     +-----------------+

Step 1: Setting Up the Node.js Backend

We will initialize a TypeScript-ready Node.js project using Express for serving static assets and the ws library for handling persistent WebSocket connections from both the ESP32 and the dashboard frontend.

Project Initialization

Open PowerShell, create your workspace directory, and initialize the project:

mkdir esp32-dashboard
cd esp32-dashboard
npm init -y

Install the required dependencies and development tools:

npm install express ws cors dotenv
npm install -D typescript @types/node @types/express @types/ws ts-node nodemon

Initialize the TypeScript configuration file:

npx tsc --init

Update your tsconfig.json to output to a dist folder and target modern Node environments:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

Implementing the WebSocket Server

Create a src directory and add server.ts. This script manages client connections, tracks the latest sensor states, and routes control commands.

mkdir src
New-Item -Path "src" -Name "server.ts" -ItemType "file"

Populate src/server.ts with the following implementation:

import express from 'express';
import { createServer } from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import cors from 'cors';
import path from 'path';

const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });

app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));

// In-memory state store
interface DeviceState {
  temperature: number;
  humidity: number;
  relayState: boolean;
  lastSeen: string;
}

let currentState: DeviceState = {
  temperature: 0.0,
  humidity: 0.0,
  relayState: false,
  lastSeen: new Date().toISOString()
};

// Keep track of connected clients (Dashboard UI vs ESP32)
const clients = new Set<WebSocket>();

wss.on('connection', (ws: WebSocket) => {
  clients.add(ws);
  console.log('[WebSocket] Client connected. Total clients:', clients.size);

  // Send current state immediately upon connection
  ws.send(JSON.stringify({ type: 'STATE_UPDATE', payload: currentState }));

  ws.on('message', (message: string) => {
    try {
      const data = JSON.parse(message.toString());
      
      if (data.type === 'TELEMETRY') {
        currentState.temperature = data.payload.temperature;
        currentState.humidity = data.payload.humidity;
        currentState.lastSeen = new Date().toISOString();
        
        // Broadcast telemetry to all UI dashboards
        broadcast({ type: 'STATE_UPDATE', payload: currentState });
      } 
      
      else if (data.type === 'TOGGLE_RELAY') {
        currentState.relayState = data.payload.relayState;
        
        // Broadcast command to ESP32 and UI clients
        broadcast({ type: 'COMMAND', payload: { relayState: currentState.relayState } });
        console.log(`[Command] Relay toggled to: ${currentState.relayState}`);
      }
    } catch (err) {
      console.error('[WebSocket] Failed to parse incoming message:', err);
    }
  });

  ws.on('close', () => {
    clients.delete(ws);
    console.log('[WebSocket] Client disconnected. Total clients:', clients.size);
  });
});

function broadcast(data: object) {
  const payload = JSON.stringify(data);
  clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(payload);
    }
  });
}

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`[Server] Dashboard backend running on http://localhost:${PORT}`);
});

Step 2: Programming the ESP32 Firmware

Next, we will write the C++ firmware using PlatformIO. The ESP32 will connect to your local Wi-Fi network, establish a persistent WebSocket connection to your Node.js server, read DHT22 environmental data every 5 seconds, and listen for relay toggle commands.

PlatformIO Project Configuration

Open VS Code, navigate to the PlatformIO extension, and create a new project named esp32-firmware using the Espressif ESP32 Dev Module board and the Arduino framework.

Open your platformio.ini file and configure your dependencies:

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps =
    adafruit/DHT sensor library @ ^1.4.4
    adafruit/Adafruit Unified Sensor @ ^1.1.9
    links2004/WebSockets @ ^2.4.1
    bblanchon/ArduinoJson @ ^6.21.3

Writing the Firmware

Replace the contents of src/main.cpp with the production firmware below. Be sure to replace "YOUR_SSID", "YOUR_PASSWORD", and "YOUR_NODE_SERVER_IP" with your local network settings.

#include <Arduino.h>
#include <WiFi.h>
#include <WebSocketsClient.h>
#include <ArduinoJson.h>
#include <DHT.h>

// Network Credentials
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";

// Node.js Server IP (Do not use localhost when running on physical ESP32)
const char* websocket_server_host = "192.168.1.150"; 
const uint16_t websocket_server_port = 3000;

// Hardware Pinouts
#log Pin Definitions
#define DHTPIN 4     // Digital pin connected to the DHT sensor
#define DHTTYPE DHT22   // DHT 22 (AM2302)
#define RELAY_PIN 23  // Digital pin connected to the relay module

DHT dht(DHTPIN, DHTTYPE);
WebSocketsClient webSocket;

unsigned long lastTelemetryMillis = 0;
const interval = 5000; // Send telemetry every 5 seconds

void handleWebSocketMessage(uint8_t *payload, size_t length) {
    StaticJsonDocument<200> doc;
    DeserializationError error = deserializeJson(doc, payload, length);

    if (error) {
        Serial.printf("deserializeJson() failed: %s\n", error.c_str());
        return;
    }

    const char* type = doc["type"];
    if (strcmp(type, "COMMAND") == 0) {
        bool relayState = doc["payload"]["relayState"];
        digitalWrite(RELAY_PIN, relayState ? HIGH : LOW);
        Serial.printf("Relay state updated via WS: %s\n", relayState ? "ON" : "OFF");
    }
}

void webSocketEvent(WStype_t type, uint8_t *payload, size_t length) {
    switch(type) {
        case WStype_DISCONNECTED:
            Serial.println("[WSC] Disconnected from Node.js server.");
            break;
        case WStype_CONNECTED:
            Serial.println("[WSC] Connected to Node.js server!");
            break;
        case WStype_TEXT:
            handleWebSocketMessage(payload, length);
            break;
        case WStype_BIN:
            break;
        case WStype_ERROR:
            Serial.println("[WSC] WebSocket Error encountered.");
            break;
        case WStype_FRAGMENT_TEXT_START:
        case WStype_FRAGMENT_BIN_START:
        case WStype_FRAGMENT:
        case WStype_FRAGMENT_FIN:
            break;
    }
}

void setup() {
    Serial.begin(115200);
    
    pinMode(RELAY_PIN, OUTPUT);
    digitalWrite(RELAY_PIN, LOW);
    
    dht.begin();

    // Connect to Wi-Fi
    WiFi.begin(ssid, password);
    Serial.print("Connecting to Wi-Fi");
    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
    }
    Serial.println("\nWi-Fi connected. IP address: ");
    Serial.println(WiFi.localIP());

    // Configure WebSocket Client
    webSocket.begin(websocket_server_host, websocket_server_port, "/");
    webSocket.onEvent(webSocketEvent);
    webSocket.setReconnectInterval(5000);
}

void loop() {
    webSocket.loop();

    unsigned long currentMillis = millis();
    if (currentMillis - lastTelemetryMillis >= interval) {
        lastTelemetryMillis = currentMillis;

        float h = dht.readHumidity();
        float t = dht.readTemperature(); // Celsius

        if (isnan(h) || isnan(t)) {
            Serial.println(F("Failed to read from DHT sensor!"));
            return;
        }

        // Build JSON telemetry payload
        StaticJsonDocument<200> doc;
        doc["type"] = "TELEMETRY";
        JsonObject payload = doc.createNestedObject("payload");
        payload["temperature"] = t;
        payload["humidity"] = h;

        String jsonString;
        serializeJson(doc, jsonString);

        webSocket.sendTXT(jsonString);
        Serial.printf("Sent Telemetry -> Temp: %.2fC | Humidity: %.2f%%\n", t, h);
    }
}

Connect your ESP32 to your Windows machine via USB, compile, and flash the firmware using PlatformIO:

pio run --target upload

Step 3: Building the Dashboard Frontend

Now, let's create a responsive HTML5 user interface that connects to our Node.js server via WebSockets. It will display real-time sensor metrics and an interactive toggle switch for the relay.

Create a public directory inside your esp32-dashboard workspace and add index.html.

mkdir public
New-Item -Path "public" -Name "index.html" -ItemType "file"

Populate public/index.html with the following clean, modern UI markup:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ESP32 Smart Home Dashboard</title>
    <style>
        :root {
            --bg-color: #0f172a;
            --card-bg: #1e293b;
            --text-primary: #f8fafc;
            --text-secondary: #94a3b8;
            --accent: #3b82f6;
            --accent-hover: #2563eb;
            --success: #22c55e;
            --danger: #ef4444;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            background-color: var(--bg-color);
            color: var(--text-primary);
            margin: 0;
            padding: 2rem;
            display: flex;
            flex-direction: column;
            align-items: center;
            min-height: 100vh;
        }

        header {
            margin-bottom: 2rem;
            text-align: center;
        }

        h1 {
            margin: 0;
            font-size: 2rem;
            font-weight: 700;
        }

        .status-badge {
            display: inline-block;
            padding: 0.25rem 0.75rem;
            border-radius: 9999px;
            font-size: 0.875rem;
            font-weight: 500;
            margin-top: 0.5rem;
        }

        .status-badge.connected {
            background-color: rgba(34, 197, 94, 0.2);
            color: var(--success);
        }

        .status-badge.disconnected {
            background-color: rgba(239, 68, 68, 0.2);
            color: var(--danger);
        }

        .grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
            gap: 1.5rem;
            width: 100%;
            max-width: 900px;
        }

        .card {
            background-color: var(--card-bg);
            border-radius: 1rem;
            padding: 1.5rem;
            box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
            display: flex;
            flex-direction: column;
            justify-content: space-between;
        }

        .card-title {
            font-size: 1rem;
            color: var(--text-secondary);
            margin-bottom: 1rem;
            text-transform: uppercase;
            letter-spacing: 0.05em;
        }

        .card-value {
            font-size: 2.5rem;
            font-weight: 700;
        }

        .switch-container {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-top: 1rem;
        }

        /* Modern Toggle Switch */
        .switch {
            position: relative;
            display: inline-block;
            width: 60px;
            height: 34px;
        }

        .switch input {
            opacity: 0;
            width: 0;
            height: 0;
        }

        .slider {
            position: absolute;
            cursor: pointer;
            top: 0; left: 0; right: 0; bottom: 0;
            background-color: #475569;
            transition: .3s;
            border-radius: 34px;
        }

        .slider:before {
            position: absolute;
            content: "";
            height: 26px;
            width: 26px;
            left: 4px;
            bottom: 4px;
            background-color: white;
            transition: .3s;
            border-radius: 50%;
        }

        input:checked + .slider {
            background-color: var(--accent);
        }

        input:checked + .slider:before {
            transform: translateX(26px);
        }
    </style>
</head>
<body>

    <header>
        <h1>ESP32 Telemetry Engine</h1>
        <div id="connection-status" class="status-badge disconnected">Disconnected</div>
    </header>

    <div class="grid">
        <!-- Temperature Card -->
        <div class="card">
            <div class="card-title">Temperature</div>
            <div class="card-value" id="temp-value">--.- °C</div>
        </div>

        <!-- Humidity Card -->
        <div class="card">
            <div class="card-title">Humidity</div>
            <div class="card-value" id="hum-value">--.- %</div>
        </div>

        <!-- Actuator Control Card -->
        <div class="card">
            <div class="card-title">Living Room Relay</div>
            <div class="switch-container">
                <span id="relay-status-text">OFF</span>
                <label class="switch">
                    <input type="checkbox" id="relay-toggle" onchange="toggleRelay(this)">
                    <span class="slider"></span>
                </label>
            </div>
        </div>
    </div>

    <script>
        const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
        const wsUrl = `${wsProtocol}//${window.location.host}`;
        let ws;

        const statusBadge = document.getElementById('connection-status');
        const tempValue = document.getElementById('temp-value');
        const humValue = document.getElementById('hum-value');
        const relayToggle = document.getElementById('relay-toggle');
        const relayStatusText = document.getElementById('relay-status-text');

        function connect() {
            ws = new WebSocket(wsUrl);

            ws.onopen = () => {
                statusBadge.textContent = 'Connected';
                statusBadge.className = 'status-badge connected';
            };

            ws.onclose = () => {
                statusBadge.textContent = 'Disconnected';
                statusBadge.className = 'status-badge disconnected';
                setTimeout(connect, 3000); // Auto-reconnect
            };

            ws.onmessage = (event) => {
                const message = JSON.parse(event.data);
                
                if (message.type === 'STATE_UPDATE') {
                    const { temperature, humidity, relayState } = message.payload;
                    tempValue.textContent = `${temperature.toFixed(1)} °C`;
                    humValue.textContent = `${humidity.toFixed(1)} %`;
                    
                    relayToggle.checked = relayState;
                    relayStatusText.textContent = relayState ? 'ON' : 'OFF';
                }
            };
        }

        function toggleRelay(element) {
            const newState = element.checked;
            relayStatusText.textContent = newState ? 'ON' : 'OFF';
            
            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.send(JSON.stringify({
                    type: 'TOGGLE_RELAY',
                    payload: { relayState: newState }
                }));
            }
        }

        connect();
    </script>
</body>
</html>

Step 4: Running and Verifying the Stack

With both the backend server and ESP32 firmware written, we can now start the local pipeline on Windows.

1. Launch the Node.js Server

Compile and execute your TypeScript server using ts-node:

npx ts-node src/server.ts

You should see the following output in your terminal:

[Server] Dashboard backend running on http://localhost:3000

2. Open the Dashboard

Open your web browser and navigate to http://localhost:3000. You will see the dark-themed dashboard with a Connected badge status.

3. Verify Hardware Telemetry

Power up your ESP32. Open the PlatformIO serial monitor to verify it connects to your local Wi-Fi and begins pushing telemetry:

pio device monitor

Output:

Connecting to Wi-Fi.....
Wi-Fi connected. IP address: 
192.168.1.150
[WSC] Connected to Node.js server!
Sent Telemetry -> Temp: 23.40C | Humidity: 48.20%

Simultaneously, your web dashboard will instantly update with the live metrics coming from the sensor without requiring page refreshes. Toggling the relay switch on the UI will immediately trigger the ESP32 to flip the physical pin high or low.


Production Deployment Considerations

If you plan to scale this setup beyond a local desk prototype, keep these engineering practices in mind:

  • Network Resilience: Implement an auto-reconnect backoff algorithm within the ESP32 firmware (WebSocketsClient handles basic reconnection, but network drops during heavy interference require graceful handling).
  • Authentication: The current architecture assumes an isolated local network. For cloud or multi-tenant deployments, secure your WebSocket handshake using JSON Web Tokens (JWT) or TLS (WSS).
  • State Persistence: Replace the in-memory currentState object in Node.js with a lightweight embedded database like SQLite or LevelDB to persist device telemetry logs across service restarts.