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
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)
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
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)
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
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
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)
WebSocket broadcaster:
const broadcast = (data) => {
const message = JSON.stringify(data)
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message)
}
})
}
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
}
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)
}
}
Keeping last 60 seconds of data for charts:
const MAX_DATA_POINTS = 60
setTempHistory((prev) => [
...prev.slice(-MAX_DATA_POINTS),
{ time, value: data.temperature }
])
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
}
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()
}
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
- GitHub: plc-web-dashboard
- My GitHub: orjinameh
If you found this useful, drop a comment below. Especially if you're working on industrial IoT or SCADA systems!
Top comments (0)