Learn how to create an ESP32 Web Server that uses ESP-NOW to control the GPIOs of multiple ESP32 boards. The web server acts like a remote control to send ESP-NOW commands to other boards to control their GPIOs. We’ll control two ESP32 boards, and two GPIOs from each one. This can be easily extended to control more ESP32 boards or more GPIOs.

You may also like reading: Building an ESP32 Web Server: The Complete Guide for Beginners.
Table of contents:
- Project Overview
- Prerequisites
- 1) Preparing the Receiver Boards
- 2) ESP32 Web Server ESP-NOW Controller/Sender Board
Project Overview
Here’s an overview of the project we’ll build.

- In this project, one ESP32 board (controller) will host a web server that displays a web page with buttons to control two GPIOs from two other ESP32 boards.
- The ESP32 controller board will act as an ESP-NOW sender that sends data to control the GPIOs of other ESP32 boards. We’ll control GPIOs 2 and 4 on ESP32 #1, and GPIOs 20 and 21 on ESP32 #2.
- The ESP32 controller board acts as an ESP-NOW sender, and the other boards act as ESP-NOW receivers.
- The controller board sends messages to a specific board by referring to its MAC address.
- To control the boards, the web server shows two cards, one for each board. Each card contains two toggle switches to control two GPIOs of each board.
- If the ESP-NOW command is not delivered successfully, the toggle switch returns to the last successfully delivered state.
New to ESP-NOW communication protocol? Read this guide: Getting Started with ESP-NOW (ESP32 with Arduino IDE).
Prerequisites
Before proceeding, make sure you follow the prerequisites. You must follow all steps, otherwise, your project will not work.
1) Parts Required
For this project, you need the following parts:

- 3x ESP32 boards of your choice (we’re using two DOIT DevKIT V1 Boards, and one ESP32S3 Freenove ESP32-CAM)
- 4x LEDs
- 4x 220 Ohm resistors
- Jumper wires
- Breaboard
You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!
2) Install ESP32 Boards in Arduino IDE

We’ll program the ESP32 using Arduino IDE. Make sure you have the ESP32 boards installed. Follow the next tutorial:
3) Install the ESPAsyncWebServer Libraries
To create the ESP32 web server, we’ll use the following libraries.
You can install these libraries in the Arduino Library Manager. Open the Library Manager by clicking the Library icon in the left sidebar.
Search for ESPAsyncWebServer and install the ESPAsyncWebServer by ESP32Async.

Search for AsyncTCP and install the AsyncTCP by ESP32Async.

1) Preparing the Receiver Boards
Follow this procedure for each of your ESP32 receiver boards. In this example, we’re using two ESP32 receiver boards, but you can use only one, three, or more.

Getting the MAC Address of the ESP32 Boards
To communicate via ESP-NOW, you need to know the MAC address of your receiver boards. To get your boards’ MAC address, upload the following code to each of your boards.
/*
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details at https://RandomNerdTutorials.com/get-change-esp32-esp8266-mac-address-arduino/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#include <WiFi.h>
#include <esp_wifi.h>
void readMacAddress(){
uint8_t baseMac[6];
esp_err_t ret = esp_wifi_get_mac(WIFI_IF_STA, baseMac);
if (ret == ESP_OK) {
Serial.printf("%02x:%02x:%02x:%02x:%02x:%02x\n",
baseMac[0], baseMac[1], baseMac[2],
baseMac[3], baseMac[4], baseMac[5]);
} else {
Serial.println("Failed to read MAC address");
}
}
void setup(){
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.STA.begin();
Serial.print("[DEFAULT] ESP32 Board MAC Address: ");
readMacAddress();
}
void loop(){
}
After uploading, open the Serial Monitor and reset the ESP32. Its MAC address will be printed. Save it because you’ll need it on the ESP32 Web Server sender code.
Get the MAC address for all your receiver boards.
For example, in my case, I get:
- ESP32 Receiver Board 1: 30:ae:a4:f6:7d:4c
- ESP32 Receiver Board 2: 34:85:18:40:2f:cc

Wiring the Circuit
We’ll control two GPIOs on each ESP32 board. We’ll control GPIOs 2 and 4 on our ESP32 DOIT board, and GPIOs 20 and 21 on our ESP32S3. You can control any other GPIOs.
We’ll connect an LED to each GPIO we want to control.
| ESP32 #1 | ESP32 #2 |
| GPIO 2 | GPIO 20 |
| GPIO 4 | GPIO 21 |
Here’s an example diagram showing how to connect two LEDs to your board.

Code – ESP-NOW ESP32 Receiver Boards
Upload the following code to each of your receiver boards. This code will work for any GPIOs you want to control.
/*********
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details at https://RandomNerdTutorials.com/esp32-esp-now-web-server-multiple-boards/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*********/
#include <esp_now.h>
#include <WiFi.h>
// Structure example to receive data
// Must match the sender structure
typedef struct struct_message {
int gpio;
bool state;
} struct_message;
// Create a struct_message called myData
struct_message myData;
// callback function that will be executed when data is received
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
memcpy(&myData, incomingData, sizeof(myData));
Serial.print("Bytes received: ");
Serial.println(len);
Serial.print("Setting GPIO: ");
Serial.println(myData.gpio);
Serial.print("state to: ");
Serial.println(myData.state);
// Control GPIO
pinMode(myData.gpio, OUTPUT);
digitalWrite(myData.gpio, myData.state);
Serial.println();
}
void setup() {
// Initialize Serial Monitor
Serial.begin(115200);
// Initialize WIFI STA interface
WiFi.mode(WIFI_STA);
// Init ESP-NOW
if (esp_now_init() != ESP_OK) {
Serial.println("Error initializing ESP-NOW");
return;
}
// Once ESPNow is successfully Init, we will register for recv CB to
// get recv packer info
esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));
}
void loop() {
}
How Does the Code Work?
Let’s take a quick look at how the receiver code works. Alternatively, you can skip to the next section.
Include Libraries
First, include the esp_now.h and the WiFi.h libraries (required to initialize the Wi-Fi station interface to use ESP-NOW).
#include <esp_now.h>
#include <WiFi.h>
Data Structure to Receive Data
Create a structure to receive the data. We call it struct_message. This structure should be the same as that defined in the sender sketch. The structure will contain the data received via ESP-NOW. The data contains the GPIO number that we want to control (int gpio) and its state (bool state).
typedef struct struct_message {
int gpio;
bool state;
} struct_message;
After creating the structure, create a struct_message variable called myData, where we’ll store the data received via ESP-NOW.
struct_message myData;
ESP-NOW Receive Callback Function
Create a callback function that is called when the ESP32 receives the data via ESP-NOW. The function is called onDataRecv() and should accept several parameters: the sender’s MAC address, the incoming message (data structure), and the message length.
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
This line copies the content of the incomingData data variable into the myData structure variable.
memcpy(&myData, incomingData, sizeof(myData));
Now, the myData structure contains two variables with the values sent by the sender board. You can access the variable gpio with myData.gpio, and access the state with myData.state. We print that data in the Serial Monitor.
Serial.print("Bytes received: ");
Serial.println(len);
Serial.print("Setting GPIO: ");
Serial.println(myData.gpio);
Serial.print("state to: ");
Serial.println(myData.state);
After receiving the data, we control the specified GPIO with the received state. First, we set the GPIO as an OUTPUT.
pinMode(myData.gpio, OUTPUT);
And finally, we control it using the digitalWrite() function and passing as an argument the GPIO (myData.gpio) and the state (myData.state)
digitalWrite(myData.gpio, myData.state);
setup()
In the setup(), initialize the Serial Monitor.
Serial.begin(115200);
Set the device as a Wi-Fi Station.
WiFi.mode(WIFI_STA);
Initialize ESP-NOW:
if (esp_now_init() != ESP_OK) {
Serial.println("Error initializing ESP-NOW");
return;
}
Register the callback function that will be called when data is received. In this case, we register for the OnDataRecv() function that was created previously.
esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));
loop()
Since we registered for a callback function when data is received, the ESP32 will be listening for ESP-NOW packets and will automatically run the callback function when a packet is received.
In this case, we don’t need to add anything to the loop(). It is empty, but you can add any code you need your board to run.
void loop() {
}
2) ESP32 Web Server ESP-NOW Controller/Sender Board
The ESP32 controller board will host a web server to send data to control the GPIOs of two other ESP32 boards. Here’s the code:
/*********
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete instructions at https://RandomNerdTutorials.com/esp32-esp-now-web-server-multiple-boards/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*********/
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <esp_now.h>
// REPLACE WITH YOUR WI-FI CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";
// REPLACE WITH YOUR RECEIVER'S MAC ADDRESS
uint8_t board1Address[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
uint8_t board2Address[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
// Structure to hold GPIO state
typedef struct struct_message {
int gpio;
bool state;
} struct_message;
struct_message board1Data;
struct_message board2Data;
// Confirmed delivered states
bool board1Btn1 = false;
bool board1Btn2 = false;
bool board2Btn1 = false;
bool board2Btn2 = false;
// Pending messages delivery tracking
struct PendingUpdate {
bool active = false;
int board;
int gpio;
bool newState;
bool oldState;
};
PendingUpdate pendingUpdate;
// GPIO pin definitions
const int board1Pin1 = 2;
const int board1Pin2 = 4;
const int board2Pin1 = 20;
const int board2Pin2 = 21;
// Global variables
esp_now_peer_info_t peerInfo;
AsyncWebServer server(80);
// Function to get a pointer to the state variable based on board and GPIO
bool*
(int board, int gpio) {
if (board == 1 && gpio == board1Pin1) return &board1Btn1;
if (board == 1 && gpio == board1Pin2) return &board1Btn2;
if (board == 2 && gpio == board2Pin1) return &board2Btn1;
if (board == 2 && gpio == board2Pin2) return &board2Btn2;
return nullptr;
}
// ESP-NOW callback function when data is sent
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
char macStr[18];
snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
Serial.print("Packet to: ");
Serial.print(macStr);
Serial.print(" send status:\t");
Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
if (!pendingUpdate.active) return;
// Ensure the pending update corresponds to the correct board and GPIO
bool* statePtr = getStatePtr(pendingUpdate.board, pendingUpdate.gpio);
if (statePtr == nullptr) {
pendingUpdate.active = false;
return;
}
// Commit or revert the state based on delivery status
if (status == ESP_NOW_SEND_SUCCESS) {
*statePtr = pendingUpdate.newState;
Serial.printf("State committed: board=%d gpio=%d state=%d\n",
pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.newState);
} else {
*statePtr = pendingUpdate.oldState;
Serial.printf("Delivery failed — state reverted: board=%d gpio=%d back to %d\n",
pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.oldState);
}
pendingUpdate.active = false;
}
// Function to send ESP-NOW message
esp_err_t sendEspNowMessage(const uint8_t *boardMacAddress, const struct_message &message) {
esp_err_t result = esp_now_send(boardMacAddress, (const uint8_t *) &message, sizeof(message));
// Handle the result of the send operation
if (result == ESP_OK) {
Serial.println("Sent with success");
} else if (result == ESP_ERR_ESPNOW_NOT_INIT) {
Serial.println("Error: ESP-NOW not initialized");
} else if (result == ESP_ERR_ESPNOW_NOT_FOUND) {
Serial.println("Error: Peer not found");
} else {
Serial.printf("Error sending the data: %d\n", result);
}
return result;
}
// HTML Web Page
String renderHTML() {
String html = R"rawliteral(
<!DOCTYPE HTML>
<html>
<head>
<title>ESP32 - ESP-NOW Controller</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: Arial; text-align: center; margin: 0; padding: 20px; background: #f4f4f4; }
h2 { color: #333; }
.card { background: white; padding: 20px; margin: 15px auto; max-width: 420px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.switch-container { margin: 15px 0; display: flex; align-items: center; justify-content: space-between; padding: 10px 0; }
.switch-label { font-size: 18px; font-weight: 500; }
.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: #ccc; transition: .4s; border-radius: 34px;
}
.slider:before {
position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 4px;
background-color: white; transition: .4s; border-radius: 50%;
}
input:checked + .slider { background-color: #4CAF50; }
input:checked + .slider:before { transform: translateX(26px); }
</style>
</head>
<body>
<h2>ESP32 - ESP-NOW Controller</h2>
)rawliteral";
html += R"rawliteral(
<div class="card">
<h3>Board #1</h3>
<div class="switch-container">
<span class="switch-label">GPIO )rawliteral";
html += String(board1Pin1);
html += R"rawliteral(</span>
<label class="switch">
<input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,)rawliteral";
html += String(board1Pin1);
html += R"rawliteral(, this)">
<span class="slider"></span>
</label>
</div>
<div class="switch-container">
<span class="switch-label">GPIO )rawliteral";
html += String(board1Pin2);
html += R"rawliteral(</span>
<label class="switch">
<input type="checkbox" id="b1Btn2" onchange="toggleSwitch(1,)rawliteral";
html += String(board1Pin2);
html += R"rawliteral(, this)">
<span class="slider"></span>
</label>
</div>
</div>
)rawliteral";
html += R"rawliteral(
<div class="card">
<h3>Board #2</h3>
<div class="switch-container">
<span class="switch-label">GPIO )rawliteral";
html += String(board2Pin1);
html += R"rawliteral(</span>
<label class="switch">
<input type="checkbox" id="b2Btn1" onchange="toggleSwitch(2,)rawliteral";
html += String(board2Pin1);
html += R"rawliteral(, this)">
<span class="slider"></span>
</label>
</div>
<div class="switch-container">
<span class="switch-label">GPIO )rawliteral";
html += String(board2Pin2);
html += R"rawliteral(</span>
<label class="switch">
<input type="checkbox" id="b2Btn2" onchange="toggleSwitch(2,)rawliteral";
html += String(board2Pin2);
html += R"rawliteral(, this)">
<span class="slider"></span>
</label>
</div>
</div>
)rawliteral";
html += R"rawliteral(
<script>
function applyStates(states) {
document.getElementById('b1Btn1').checked = states.b1Btn1 || false;
document.getElementById('b1Btn2').checked = states.b1Btn2 || false;
document.getElementById('b2Btn1').checked = states.b2Btn1 || false;
document.getElementById('b2Btn2').checked = states.b2Btn2 || false;
}
async function loadStates() {
try {
const response = await fetch('/states');
const states = await response.json();
applyStates(states);
} catch (err) {
console.error("Failed to load states:", err);
}
}
function toggleSwitch(board, gpio, element) {
if (!element) {
console.error("Element is undefined");
return;
}
const state = element.checked;
fetch(`/control?board=${board}&gpio=${gpio}&state=${state ? 1 : 0}`)
.then(response => response.text())
.then(data => console.log(data))
.catch(err => {
console.error(err);
element.checked = !element.checked;
});
}
window.onload = function() {
loadStates();
setInterval(loadStates, 5000);
};
</script>
</body>
</html>
)rawliteral";
return html;
}
void setup() {
Serial.begin(115200);
// Set device as a Wi-Fi Station and establish a Wi-Fi connection
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWi-Fi Connected!");
// Print the ESP32 IP Address
Serial.print("Access ESP32 IP Address: http://");
Serial.println(WiFi.localIP());
// Init ESP-NOW
if (esp_now_init() != ESP_OK) {
Serial.println("Error initializing ESP-NOW");
return;
}
// Once ESPNow is successfully Init, we will register for Send CB to
// get the status of Trasnmitted packet
esp_now_register_send_cb(esp_now_send_cb_t(OnDataSent));
// Register peers
peerInfo.channel = 0;
peerInfo.encrypt = false;
// Register first peer
memcpy(peerInfo.peer_addr, board1Address, 6);
if (esp_now_add_peer(&peerInfo) != ESP_OK) {
Serial.println("Failed to add peer");
return;
}
// Register second peer
memcpy(peerInfo.peer_addr, board2Address, 6);
if (esp_now_add_peer(&peerInfo) != ESP_OK) {
Serial.println("Failed to add peer");
return;
}
// Root URL handler
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(200, "text/html", renderHTML());
});
// LED Control via ESP-NOW
server.on("/control", HTTP_GET, [](AsyncWebServerRequest *request){
// Validate URL parameters
if (request->hasParam("board") && request->hasParam("gpio") && request->hasParam("state")) {
int board = request->getParam("board")->value().toInt();
int gpio = request->getParam("gpio")->value().toInt();
bool newState = request->getParam("state")->value().toInt() == 1;
bool* statePtr = getStatePtr(board, gpio);
if (statePtr == nullptr) {
request->send(400, "text/plain", "Invalid board/gpio");
return;
}
// Set up the pending update for delivery tracking
pendingUpdate.active = true;
pendingUpdate.board = board;
pendingUpdate.gpio = gpio;
pendingUpdate.newState = newState;
pendingUpdate.oldState = *statePtr;
struct_message msg;
msg.gpio = gpio;
msg.state = newState;
uint8_t* target = (board == 1) ? board1Address : board2Address;
esp_err_t result = sendEspNowMessage(target, msg);
if (result != ESP_OK) {
// If sending failed, revert the GPIO state immediately
pendingUpdate.active = false;
request->send(500, "text/plain", "Send failed (not transmitted)");
} else {
// If sending succeeded, wait for delivery confirmation in the callback
request->send(200, "text/plain", "Sent, awaiting delivery confirmation");
}
} else {
request->send(400, "text/plain", "Missing parameters");
}
});
// Get confirmed states of each GPIO by board
server.on("/states", HTTP_GET, [](AsyncWebServerRequest *request){
// Return the confirmed states of each GPIO by board in JSON format
String json = "{";
json += "\"b1Btn1\":" + String(board1Btn1 ? "true" : "false") + ",";
json += "\"b1Btn2\":" + String(board1Btn2 ? "true" : "false") + ",";
json += "\"b2Btn1\":" + String(board2Btn1 ? "true" : "false") + ",";
json += "\"b2Btn2\":" + String(board2Btn2 ? "true" : "false");
json += "}";
request->send(200, "application/json", json);
});
server.begin();
Serial.println("ESP32 Web Server is Ready!");
}
void loop() {
}
Before uploading the following code to your ESP32 controller board, you need to insert your SSID and password and the MAC addresses of the receiver boards in the following lines:
// REPLACE WITH YOUR WI-FI CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";
// REPLACE WITH YOUR RECEIVER'S MAC ADDRESS
uint8_t board1Address[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
uint8_t board2Address[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
How Does the Code Work?
Continue reading to learn how the code works, or skip to the demonstration section.
Including Libraries
Include the libraries to use ESP-NOW and to build the web server.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <esp_now.h>
Network Credentials
Include your network credentials in the following lines so that the ESP32 can connect to your router via Wi-Fi.
// REPLACE WITH YOUR WI-FI CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";
Receiver Boards’ MAC Address
Modify the following lines with the MAC address of your receiver boards. For example, in my case:
- ESP32 Receiver Board 1: 30:ae:a4:f6:7d:4c
- ESP32 Receiver Board 2: 34:85:18:40:2f:cc
So, it will be like this:
// REPLACE WITH YOUR RECEIVER'S MAC Address
uint8_t board1Address[] = {0x30, 0xAE, 0xA4, 0xF6, 0x7D, 0x4C};
uint8_t board2Address[] = {0x34, 0x85, 0x18, 0x40, 0x2F, 0xCC};
Data Structure to Send Data
Create a data structure to send data to the other ESP32 boards to control their GPIOs. This must be the same structure set in the receiver boards. The structure contains the GPIO to be controlled (int gpio) and the state (bool state), either ON (true) or OFF(false).
// Structure example to send data
// Must match the receiver structure
typedef struct struct_message {
int gpio;
bool state;
} struct_message;
Now, created two variables of type struct_message, one for each board: board1Data and board2Data.
struct_message board1Data;
struct_message board2Data;
Confirming Delivered States
To confirm whether each button state is delivered, we create a variable for each button on the web server page.
// Confirmed delivered states
bool board1Btn1 = false;
bool board1Btn2 = false;
bool board2Btn1 = false;
bool board2Btn2 = false;
We also create a structure that will help us track whether the message was delivered, which GPIO we want to control, the new GPIO state, and its old state. This will help decide whether to change the GPIO state on the interface depending on the message delivery status.
// Pending messages delivery tracking
struct PendingUpdate {
bool active = false;
int board;
int gpio;
bool newState;
bool oldState;
};
We create a new variable called pendingUpdate of type PendingUpdate.
PendingUpdate pendingUpdate;
Global Variables
We create global variables to refer to the GPIOs we want to control. We’ll control GPIOs 2 and 4 from board 1, and GPIOs 20 and 21 from board 2. You can adjust to control any GPIOs of your choice.
// GPIO pin definitions
const int board1Pin1 = 2;
const int board1Pin2 = 4;
const int board2Pin1 = 20;
const int board2Pin2 = 21;
Create a variable of type esp_now_peer_info that will be used to hold data about ESP-NOW peers (the receiver boards).
esp_now_peer_info_t peerInfo;
Create an AsyncWebServer instance called server on port 80 that refers to our web server.
AsyncWebServer server(80);
OnDataSent() Callback Function
The OnDataSent() callback function will run when we send an ESP-NOW message.
// ESP-NOW callback function when data is sent
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
char macStr[18];
snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
Serial.print("Packet to: ");
Serial.print(macStr);
Serial.print(" send status:\t");
Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
if (!pendingUpdate.active) return;
// Ensure the pending update corresponds to the correct board and GPIO
bool* statePtr = getStatePtr(pendingUpdate.board, pendingUpdate.gpio);
if (statePtr == nullptr) {
pendingUpdate.active = false;
return;
}
// Commit or revert the state based on delivery status
if (status == ESP_NOW_SEND_SUCCESS) {
*statePtr = pendingUpdate.newState;
Serial.printf("State committed: board=%d gpio=%d state=%d\n",
pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.newState);
} else {
*statePtr = pendingUpdate.oldState;
Serial.printf("Delivery failed — state reverted: board=%d gpio=%d back to %d\n",
pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.oldState);
}
pendingUpdate.active = false;
}
This function checks whether the message was successfully delivered and prints the result in the Serial Monitor.
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
char macStr[18];
snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
Serial.print("Packet to: ");
Serial.print(macStr);
Serial.print(" send status:\t");
Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
Then, it also checks if there is a pending state change. This happens when we click the toggle switches and are waiting for the delivery confirmation.
if (!pendingUpdate.active) return;
If the message is successfully delivered, we commit the new state. Otherwise, we revert to the old state.
// Commit or revert the state based on delivery status
if (status == ESP_NOW_SEND_SUCCESS) {
*statePtr = pendingUpdate.newState;
Serial.printf("State committed: board=%d gpio=%d state=%d\n",
pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.newState);
} else {
*statePtr = pendingUpdate.oldState;
Serial.printf("Delivery failed — state reverted: board=%d gpio=%d back to %d\n",
pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.oldState);
}
Finally, we set the pendingUpdate variable to False because we have already confirmed the delivery.
pendingUpdate.active = false;
The following diagram will help you better understand how it works.

Send ESP-NOW Message
The sendespNowMessage() sends a message via ESP-NOW. It accepts the receiver board’s MAC address and the message to deliver as arguments. It prints and returns the result.
// Function to send ESP-NOW message
esp_err_t sendEspNowMessage(const uint8_t *boardMacAddress, const struct_message &message) {
esp_err_t result = esp_now_send(boardMacAddress, (const uint8_t *) &message, sizeof(message));
// Handle the result of the send operation
if (result == ESP_OK) {
Serial.println("Sent with success");
} else if (result == ESP_ERR_ESPNOW_NOT_INIT) {
Serial.println("Error: ESP-NOW not initialized");
} else if (result == ESP_ERR_ESPNOW_NOT_FOUND) {
Serial.println("Error: Peer not found");
} else {
Serial.printf("Error sending the data: %d\n", result);
}
return result;
}
Render the HTML Page
The renderHTML() function creates the web page with HTML, CSS, and JavaScript.

The web page is concatenated with the GPIOs we want to control (board1Pin1, board1Pin2, board2Pin1, board2Pin2). This makes it easier to adjust the web page to any GPIOs you want to control.
String renderHTML() {
String html = R"rawliteral(
<!DOCTYPE HTML>
<html>
(...)
</html>
)rawliteral";
return html;
}
Styling the Web Page
First, we include the CSS to style the webpage between the <style> </style> tags.
<style>
body { font-family: Arial; text-align: center; margin: 0; padding: 20px; background: #f4f4f4; }
h2 { color: #333; }
.card { background: white; padding: 20px; margin: 15px auto; max-width: 420px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.switch-container { margin: 15px 0; display: flex; align-items: center; justify-content: space-between; padding: 10px 0; }
.switch-label { font-size: 18px; font-weight: 500; }
.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: #ccc; transition: .4s; border-radius: 34px;
}
.slider:before {
position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 4px;
background-color: white; transition: .4s; border-radius: 50%;
}
input:checked + .slider { background-color: #4CAF50; }
input:checked + .slider:before { transform: translateX(26px); }
</style>
Toggle Switches
Then, we have the HTML section to create a card with two toggle switches for board 1.
html += R"rawliteral(
<div class="card">
<h3>Board #1</h3>
<div class="switch-container">
<span class="switch-label">GPIO )rawliteral";
html += String(board1Pin1);
html += R"rawliteral(</span>
<label class="switch">
<input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,)rawliteral";
html += String(board1Pin1);
html += R"rawliteral(, this)">
<span class="slider"></span>
</label>
</div>
<div class="switch-container">
<span class="switch-label">GPIO )rawliteral";
html += String(board1Pin2);
html += R"rawliteral(</span>
<label class="switch">
<input type="checkbox" id="b1Btn2" onchange="toggleSwitch(1,)rawliteral";
html += String(board1Pin2);
html += R"rawliteral(, this)">
<span class="slider"></span>
</label>
</div>
</div>
)rawliteral";
Notice that to create the webpage, we concatenate the value of the board1Pin1 and board1Pin2 variables with the rest of the webpage. This makes the web server more versatile. You only need to change the variable values to control other GPIOs.
For example, in our case:
board1Pin1 = 2;
So, this piece of code:
<span class="switch-label">GPIO )rawliteral";
html += String(board1Pin1);
html += R"rawliteral(</span>
Becomes:
<span class="switch-label">GPIO 2</span>
And this section:
html += R"rawliteral(</span>
<label class="switch">
<input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,)rawliteral";
html += String(board1Pin1);
html += R"rawliteral(, this)">
Will be like this:
<input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,2, this)">
It is similar for the other toggle switches.
Handle Toggle Switch
When we click on the toggle switches, they’ll call the toggleSwitch() JavaScript function. They pass as arguments the board the toggle switch refers to, the GPIO, and the toggle switch element (this) to identify which switch was clicked.
<input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,2, this)">
The toggleSwitch() function is defined at the <script> </script> section of the HTML.
function toggleSwitch(board, gpio, element) {
if (!element) {
console.error("Element is undefined");
return;
}
const state = element.checked;
fetch(`/control?board=${board}&gpio=${gpio}&state=${state ? 1 : 0}`)
.then(response => response.text())
.then(data => console.log(data))
.catch(err => {
console.error(err);
element.checked = !element.checked;
});
}
For example, if you click on the first toggle switch, board=1, gpio=2, and element = toggle switch that was clicked. So, it will execute:
toggleSwitch(1, 2, this);
It checks the toggle switch state:
const state = element.checked;
And then, we make a request with the appropriate board, GPIO, and state:
fetch(`/control?board=${board}&gpio=${gpio}&state=${state ? 1 : 0}`)
For example, in the case of the first toggle switch, it will make a request like this:
/control?board=1&gpio=2&state=1
When the ESP32 sends a response, we convert it into text and print it to the JavaScript console.
.then(response => response.text())
.then(data => console.log(data))
In case the request is not successful (the ESP32 didn’t receive the request), we revert the toggle switch to its previous state.
.catch(err => {
console.error(err);
element.checked = !element.checked;
Updating Toggle Switch States
When the page first loads, the browser doesn’t know the actual GPIO states stored on the ESP32. All checkboxes might initially appear unchecked (toggle switches turned off).
When the window first loads, we call the loadStates() function to set the toggle switches’ states according to the GPIO states.
window.onload = function() {
loadStates();
setInterval(loadStates, 5000);
The loadStates() function is also called every five seconds. This is done to update the webpage according to the states of the GPIOs (in case the message is not delivered).
setInterval(loadStates, 5000);
So, what happens is: the webpage loads, and JavaScript calls the loadStates() function that makes a request on the /states URL:
async function loadStates() {
try {
const response = await fetch('/states');
const states = await response.json();
applyStates(states);
} catch (err) {
console.error("Failed to load states:", err);
}
}
The ESP32 responds with JSON containing the current states. We read those values (applyStates()):
const states = await response.json();
applyStates(states);
Each checkbox’s .checked property is updated to reflect the current GPIO states. The webpage now reflects the current device state with the toggle switches’ values matching the corresponding state received by the ESP32.
function applyStates(states) {
document.getElementById('b1Btn1').checked = states.b1Btn1 || false;
document.getElementById('b1Btn2').checked = states.b1Btn2 || false;
document.getElementById('b2Btn1').checked = states.b2Btn1 || false;
document.getElementById('b2Btn2').checked = states.b2Btn2 || false;
}
setup()
In the setup(), initialize the Serial Monitor for debugging.
void setup() {
Serial.begin(115200);
Set the ESP32 as a Wi-Fi station, connect it to your router, and print the ESP32 IP address.
// Set device as a Wi-Fi Station and establish a Wi-Fi connection
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWi-Fi Connected!");
// Print the ESP32 IP Address
Serial.print("Access ESP32 IP Address: http://");
Serial.println(WiFi.localIP());
Initialize ESP-NOW.
// Init ESP-NOW
if (esp_now_init() != ESP_OK) {
Serial.println("Error initializing ESP-NOW");
return;
}
ESP-NOW callback function
Register for an ESP-NOW Sending Callback Function. In this case, it’s the OnDataSent() we’ve seen previously.
esp_now_register_send_cb(esp_now_send_cb_t(OnDataSent));
ESP-NOW Peers
Register the receiver boards as ESP-NOW peers.
// Register peers
peerInfo.channel = 0;
peerInfo.encrypt = false;
// Register first peer
memcpy(peerInfo.peer_addr, board1Address, 6);
if (esp_now_add_peer(&peerInfo) != ESP_OK){
Serial.println("Failed to add peer");
return;
}
// Register second peer
memcpy(peerInfo.peer_addr, board2Address, 6);
if (esp_now_add_peer(&peerInfo) != ESP_OK){
Serial.println("Failed to add peer");
return;
}
Handle Web Server
Now, let’s handle what happens when you access the ESP32 web server on your browser.
When you access the root / URL (by typing the ESP32 IP address in the web browser), we’ll send the rendered HTML page with the current GPIO states (renderHTML() function).
// Root URL handler
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(200, "text/html", renderHTML());
});
The following lines handle what happens when the ESP32 receives a request on the /states URL endpoint. In this case, we prepare a JSON variable containing the current state of each button.
For example, if board1Btn1 is true, the JSON returned is:
"b1Btn1":true
The same happens for the other variables.
When the JSON variable is ready, we send a response with the JSON string.
request->send(200, "application/json", json);
Finally, we start the web server.
server.begin();
loop()
Because everything works asynchronously with callback functions, we don’t need to add anything to the loop().
void loop() {
}
Testing the Web Server
Upload the web server code to the ESP32 controller board. After uploading, open the Serial Monitor at a baud rate of 115200 and press the ESP32 RST button. Its IP address will be printed.

Open a web browser on your local network (on your computer or smartphone) and type the ESP32 IP address. You’ll get access to the following web page to control the GPIOs of the other ESP32 boards.

Use the toggle switches to control the GPIOs of the other ESP32 boards.
If you open a Serial Monitor window for each of your boards, you’ll see the message being sent and the message being delivered on the receiver board.

If the message isn’t delivered successfully, the toggle switch will revert to its previous state.

You can watch the following quick video demonstration.
Wrapping Up
In this tutorial, you built an ESP32 web server that controls other ESP32 boards using ESP-NOW. This demonstrates just how versatile the ESP-NOW protocol is. In our opinion, it is one of the best communication protocols to have multiple ESP32 boards communicating with each other.
Here are other related tutorials you may like:
- ESP32 Wireless Communication Protocols
- ESP32 CYD with ESP-NOW: Control Multiple ESP32 Boards
- ESP32: ESP-NOW Web Server Sensor Dashboard (ESP-NOW + Wi-Fi)
- MicroPython: ESP-NOW with ESP32 (Getting Started)
Learn more about the ESP32 with our resources:




