DEV Community

orjinameh
orjinameh

Posted on

How I Built a Real-Time Industrial PLC Monitoring Dashboard with Next.js and Modbus TCP(SCADA project)

How I Built a Real-Time Industrial PLC Monitoring Dashboard with Next.js and Modbus TCP

As a Mechatronics Engineering student with 4 years of web development experience, I've always wanted to build something that combines both worlds. This project is exactly that — a real-time industrial SCADA-like dashboard that reads data from a CODESYS virtual PLC via Modbus TCP and displays it on a Next.js web interface.

This is not a tutorial about blinking LEDs. This is how real industrial systems work.


What I Built

A full-stack industrial monitoring system with:

  • CODESYS virtual PLC running a simulated conveyor belt process
  • Modbus TCP communication between PLC and server
  • Node.js + WebSockets for real-time data streaming
  • Next.js dashboard with live charts and alarm management
  • MongoDB for historical data storage
  • JWT authentication to protect the dashboard

Why This Project Matters

In real factories, power plants and oil refineries, operators monitor industrial processes through SCADA systems (Supervisory Control and Data Acquisition). These systems read data from PLCs (Programmable Logic Controllers) via industrial protocols like Modbus TCP, OPC-UA or Profinet.

Most web developers have never heard of Modbus. Most Mechatronics engineers don't know React. This project sits exactly at that intersection.


System Architecture

CODESYS Virtual PLC
        ↓
Modbus TCP (port 502)
        ↓
Node.js Server
   ├── WebSocket Server (live data)
   ├── REST API (historical data)
   └── MongoDB (storage)
        ↓
Next.js Dashboard
   ├── Live status cards
   ├── Real-time charts (Recharts)
   ├── Alarm management
   └── Historical data page
Enter fullscreen mode Exit fullscreen mode

What is Modbus TCP?

Modbus is an industrial communication protocol developed in 1979. It's still the most widely used protocol in industrial automation today.

Modbus TCP works over standard Ethernet using a simple request/response model:

Node.js (Master) → "Read registers 0-3" → PLC (Slave)
PLC (Slave) → "Values: 254, 120, 50, 142" → Node.js (Master)
Enter fullscreen mode Exit fullscreen mode

Each register holds one 16-bit integer value. PLCs store sensor readings as integers because they don't support decimals natively:

Temperature 25.4°C → stored as 254 → divide by 10 → 25.4
Pressure 1.20 bar → stored as 120 → divide by 100 → 1.20
Enter fullscreen mode Exit fullscreen mode

Setting Up CODESYS

CODESYS is a free industrial PLC programming environment used by Wago, Beckhoff, Phoenix Contact and many other PLC manufacturers. The same code runs on virtual and real PLCs.

Project structure:

Device (CODESYS Control Win V3 x64)
  └── Ethernet Adapter
        └── Modbus TCP Server (port 502)
  └── Application
        └── PLC_PRG (Structured Text)
Enter fullscreen mode Exit fullscreen mode

PLC variables:

VAR
  Temperature : UINT := 254;  (* 25.4°C *)
  Pressure    : UINT := 120;  (* 1.20 bar *)
  BeltSpeed   : UINT := 50;   (* 50 rpm *)
  ItemCount   : UINT := 0;    (* items produced *)
  Counter     : UINT := 0;
END_VAR
Enter fullscreen mode Exit fullscreen mode

Ladder logic to simulate a dynamic process:

// Simulate temperature rising and falling
Counter := Counter + 1;

IF Counter >= 100 THEN
  Counter := 0;
END_IF

Temperature := 240 + Counter;

// Pressure responds to temperature
IF Temperature > 270 THEN
  Pressure := 150;
ELSIF Temperature > 250 THEN
  Pressure := 130;
ELSE
  Pressure := 110;
END_IF

// Belt speed fluctuates
IF Counter MOD 20 = 0 THEN
  BeltSpeed := 40 + (Counter / 10);
END_IF

// Item count increases
IF Counter MOD 10 = 0 THEN
  ItemCount := ItemCount + 1;
END_IF
Enter fullscreen mode Exit fullscreen mode

The variables are mapped to Modbus Input Registers via the I/O mapping table so our Node.js server can read them.


Node.js Server

Reading PLC data via Modbus TCP:

const ModbusRTU = require('modbus-serial')

const client = new ModbusRTU()

const connectModbus = async () => {
  await client.connectTCP('127.0.0.1', { port: 502 })
  client.setID(1)
  console.log('Modbus connected!')
}

const readPLCData = async () => {
  const registers = await client.readInputRegisters(0, 4)

  const data = {
    temperature: registers.data[0] / 10,  // 254 → 25.4°C
    pressure: registers.data[1] / 100,    // 120 → 1.20 bar
    beltSpeed: registers.data[2],         // 50 rpm
    itemCount: registers.data[3],         // 142 items
    alarms: getAlarms(registers.data),
    timestamp: new Date()
  }

  broadcast(data)           // send to dashboard via WebSocket
  await saveToDB(data)      // store in MongoDB
}

// Read every second
setInterval(readPLCData, 1000)
Enter fullscreen mode Exit fullscreen mode

WebSocket broadcaster:

const broadcast = (data) => {
  const message = JSON.stringify(data)

  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message)
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Alarm detection:

const getAlarms = (data) => {
  const alarms = []

  if (data[0] / 10 > 80)  alarms.push('HIGH_TEMPERATURE')
  if (data[1] / 100 < 0.5) alarms.push('LOW_PRESSURE')
  if (data[2] === 0)        alarms.push('BELT_STOPPED')

  return alarms
}
Enter fullscreen mode Exit fullscreen mode

Next.js Dashboard

Connecting to WebSocket:

export const connectWebSocket = (
  onData: (data: PlcData) => void,
  onConnect: () => void,
  onDisconnect: () => void
) => {
  const socket = new WebSocket('ws://localhost:4000')

  socket.onmessage = (event) => {
    const data: PlcData = JSON.parse(event.data)
    onData(data)
  }

  socket.onclose = () => {
    onDisconnect()
    // Auto reconnect after 3 seconds
    setTimeout(() => connectWebSocket(onData, onConnect, onDisconnect), 3000)
  }
}
Enter fullscreen mode Exit fullscreen mode

Keeping last 60 seconds of data for charts:

const MAX_DATA_POINTS = 60

setTempHistory((prev) => [
  ...prev.slice(-MAX_DATA_POINTS),
  { time, value: data.temperature }
])
Enter fullscreen mode Exit fullscreen mode

Status card with dynamic color:

const getStatus = (value, warnThreshold, dangerThreshold) => {
  if (value >= dangerThreshold) return 'danger'   // red
  if (value >= warnThreshold) return 'warning'    // yellow
  return 'normal'                                  // green
}
Enter fullscreen mode Exit fullscreen mode

JWT Authentication

To protect the dashboard, I added JWT authentication:

// Login endpoint
const login = async (req, res) => {
  const user = await User.findOne({ username: req.body.username })
  const isValid = await user.comparePassword(req.body.password)

  if (!isValid) return res.status(401).json({ error: 'Invalid credentials' })

  const token = jwt.sign(
    { id: user._id, username: user.username, role: user.role },
    JWT_SECRET,
    { expiresIn: '24h' }
  )

  res.json({ token, username: user.username, role: user.role })
}

// Protect routes
const protect = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1]
  const decoded = jwt.verify(token, JWT_SECRET)
  req.user = decoded
  next()
}
Enter fullscreen mode Exit fullscreen mode

User roles: admin, operator, viewer — ready for role-based access control.


Key Lessons

1. Modbus TCP uses Input Registers for reading, Holding Registers for writing

I initially mapped variables to Holding Registers and got all zeros. The fix was using Input Registers for PLC → external device communication.

2. Read state before dispatching in real-time systems

When detecting transitions (alarm on/off), always compare old state vs new state before updating.

3. Industrial protocols are simpler than you think

Modbus is just: connect → set ID → read registers → disconnect. The modbus-serial npm package handles everything.

4. WebSockets beat REST for real-time data

Polling a REST endpoint every second creates unnecessary overhead. WebSockets maintain a persistent connection and push data only when it changes.


What's Next

  • Connect to a real PLC (Automation Direct Click Plus ~$100)
  • Add OPC-UA support (more modern than Modbus)
  • Add role-based access control
  • Deploy to a VPS with Docker

Tech Stack Summary

Layer Technology
PLC Simulation CODESYS Control Win V3
Protocol Modbus TCP
Backend Node.js + Express
Real-time WebSockets (ws)
Database MongoDB + Mongoose
Auth JWT + bcryptjs
Frontend Next.js + TypeScript
Charts Recharts
Styling Tailwind CSS

Links

If you found this useful, drop a comment below. Especially if you're working on industrial IoT or SCADA systems!

Top comments (0)