Switch a relay from a web page: a tiny 3.3 V signal controlling a much larger circuit.
Project_7_ESP32_Relay_Web_Server.ino, then open it in the Arduino IDE (setup: Foundation 3). Needs the ESPAsyncWebServer + AsyncTCP libraries and your Wi‑Fi credentials.
By the end of this project, you will:
PROGMEM, filled in at send time by a template processor.?relay=1&state=1 instead of parsing the URL by hand.A relay is an electrically‑operated switch. A small coil current (from an ESP32 GPIO) flips a mechanical contact, connecting or disconnecting a separate higher‑power circuit: the two sides electrically isolated.




This project uses the asynchronous server libraries: install both:
Add each via Sketch → Include Library → Add .ZIP Library…, then restart the IDE.

Set your Wi‑Fi credentials, upload, open Serial Monitor at 115200, press EN/RESET:
============================================== Project 7: ESP32 Relay Web Server ============================================== 2 relay(s), mode: Normally Open (NO) Relay #1 -> GPIO 26 Relay #2 -> GPIO 27 Connecting to Wi-Fi: MyNetwork .... Wi-Fi connected! Open this address in your browser: http://192.168.1.42 ---------------------------------------------- [WEB] Relay #1 -> ON
Open the address; flip a toggle → the relay clicks and the clean line above prints (the original sketch printed a cryptic NO 11).
This sketch looks different from Projects 5 and 6, and the difference is worth understanding: the async library takes over the request handling you previously wrote by hand.
#define RELAY_NO true // relays wired Normally Open #define NUM_RELAYS 2 // how many relays on the module int relayGPIOs[NUM_RELAYS] = {26, 27}; AsyncWebServer server(80);
Everything you might change lives in one block. relayGPIOs is an array, so adding a third relay means adding a pin and raising NUM_RELAYS: no other code changes. RELAY_NO records how the relay is wired, which decides the logic further down.
const char index_html[] PROGMEM = R"rawliteral( <!DOCTYPE HTML><html> ... %BUTTONPLACEHOLDER% ... )rawliteral";
R"rawliteral( ... )rawliteral" is a raw string literal: everything between the markers is taken literally, so HTML can contain quotes and newlines without escaping. Compare Project 5, where every line was a separate client.println().PROGMEM keeps the page in flash instead of copying it into RAM at startup. The ESP32 has plenty of flash and much less RAM.%BUTTONPLACEHOLDER% is a marker the library replaces when the page is sent.
setup() puts the relays in a safe state, then registers routesfor (int i = 1; i <= NUM_RELAYS; i++) { pinMode(relayGPIOs[i-1], OUTPUT); if (RELAY_NO) digitalWrite(relayGPIOs[i-1], HIGH); // NO: HIGH = off }
Starting with every relay off matters more here than with LEDs: a relay that switches on at power-up would energise whatever it controls.
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ request->send_P(200, "text/html", index_html, processor); }); server.on("/update", HTTP_GET, [](AsyncWebServerRequest *request){ if (request->hasParam(PARAM_INPUT_1) && request->hasParam(PARAM_INPUT_2)) { int relayNum = request->getParam(PARAM_INPUT_1)->value().toInt(); int wantOn = request->getParam(PARAM_INPUT_2)->value().toInt(); if (RELAY_NO) digitalWrite(relayGPIOs[relayNum-1], !wantOn); // NO: LOW = on } request->send(200, "text/plain", "OK"); }); server.begin();
server.on(path, method, handler) says "when someone asks for this URL, run this function". The [](AsyncWebServerRequest *request){ ... } is a lambda, a function written inline. You register routes once and the library calls them for you.
!wantOn. On a normally open module the relay closes when the control pin goes LOW, so the sketch inverts the value: asking for "on" (1) writes LOW (0).processor() builds the switchesString processor(const String& var){ if (var == "BUTTONPLACEHOLDER") { String buttons = ""; for (int i = 1; i <= NUM_RELAYS; i++) { buttons += /* one toggle switch per relay */; } return buttons; } return String(); }
When the library meets %BUTTONPLACEHOLDER% it calls processor("BUTTONPLACEHOLDER") and drops in whatever comes back, so the page grows automatically if you add relays. relayState() reads each pin back and returns "checked" or "", drawing each switch in the position matching the real relay.
loop()void loop() { }
Not a mistake. With the async server, requests are handled in the background, so nothing is left for loop() to do. That free loop() is exactly what makes room for Project 8 to poll a button while still serving pages.
| Code | What it does |
|---|---|
AsyncWebServer server(80) | An event-driven server: you register handlers and it deals with clients itself, unlike WiFiServer where you read the request byte by byte. |
PROGMEM | Keeps the page in flash rather than RAM. The ESP32 has around 520 KB of RAM but megabytes of flash. |
R"rawliteral(...)rawliteral" | A raw string: quotes and newlines need no escaping, so HTML can be pasted in as-is. |
server.on(path, HTTP_GET, handler) | Registers a route: a URL plus the function to run for it. |
request->hasParam("relay") | Checks a query parameter exists before reading it, so a malformed URL cannot crash the sketch. |
request->getParam("relay")->value() | Reads ?relay=1 and hands back "1", replacing the indexOf/substring parsing of Project 6. |
send_P(200, "text/html", index_html, processor) | Sends a page from flash (_P) and runs processor over its placeholders on the way out. |
digitalWrite(pin, !wantOn) | Inverts the command for active-low hardware: LOW energises a normally-open relay. |
void loop() {} | Deliberately empty: the async server needs no polling. |

You can now switch a real electrical load from a browser, and you have met the server library the rest of the kit uses.
setup().loop() can be empty.?relay=1&state=1) are the normal way to pass values in a URL, and hasParam() / getParam() read them safely.PROGMEM keeps RAM free, and a template processor fills in the changing parts at send time.| Symptom | Likely cause | Fix |
|---|---|---|
Compile error on ESPAsyncWebServer | Libraries missing | Install ESPAsyncWebServer and AsyncTCP |
| Relay clicks, load doesn't switch | Wired to NC | Use COM → NO |
| Toggle seems inverted | NO‑mode inversion (normal) | Leave the sketch as‑is |
| No click at all | IN mis‑wired / module unpowered | IN → GPIO 26/27; VCC→VIN, GND→GND |
| Stuck on "Connecting…" | Wrong credentials / 5 GHz | Fix credentials; use 2.4 GHz |