Phase 4: Connectivity 16 min read

ESP32 Web Server — Serve an HTML Control Page

Build a local web server on ESP32 to control GPIO pins from a browser. Covers the WebServer library, route handlers, HTML forms, JSON API endpoints, and mDNS hostname setup.

Updated June 18, 2026

What You Will Build

A local web page hosted on the ESP32 itself that lets you control an LED from any browser on your network. By the end of this guide you will have a working URL like http://192.168.1.120/ that shows LED state, toggles it on/off, and returns live status as JSON.

Hardware Setup

  • LED + 220 Ω resistor connected to GPIO2 (built-in LED on most DevKit boards)
  • ESP32 DevKit connected via USB

Minimal Web Server

web_server_basic.ino
#include <WiFi.h>
#include <WebServer.h>

const char* ssid     = "YourSSID";
const char* password = "YourPassword";
const int   LED_PIN  = 2;

WebServer server(80);
bool ledState = false;

String buildPage() {
  String html = "

Setting Up mDNS (Access by Name)

Instead of remembering an IP address, configure mDNS so the device is reachable at a friendly hostname:

mdns_setup.ino
#include <ESPmDNS.h>

// In setup(), after WiFi connects:
if (MDNS.begin("esp32-control")) {
  MDNS.addService("http", "tcp", 80);
  Serial.println("mDNS: http://esp32-control.local/");
}

Now any device on the same LAN can browse to http://esp32-control.local/ without knowing the IP.

Real-Time Updates with AJAX Polling

Refresh sensor data without a full page reload by adding a small JavaScript fetch loop to your HTML:

ajax_poll.html (inside the String)
<div id="status">Loading…</div>
<script>
setInterval(async () => {
  const r = await fetch('/status');
  const d = await r.json();
  document.getElementById('status').textContent =
    'LED: ' + (d.led ? 'ON' : 'OFF') + ' | Uptime: ' + d.uptime + 's';
}, 2000);
</script>

Serving Multiple GPIO Pins

multi_gpio.ino
void handlePin() {
  if (server.hasArg("pin") && server.hasArg("state")) {
    int  pin   = server.arg("pin").toInt();
    bool state = server.arg("state") == "1";
    if (pin >= 0 && pin <= 39) {
      pinMode(pin, OUTPUT);
      digitalWrite(pin, state ? HIGH : LOW);
      server.send(200, "application/json", "{\"ok\":true}");
    } else {
      server.send(400, "application/json", "{\"error\":\"invalid pin\"}");
    }
  }
}
// Register: server.on("/pin", HTTP_GET, handlePin);
// Call: GET /pin?pin=4&state=1

Receiving POST Data from a Form

post_handler.ino
void handleForm() {
  if (server.method() == HTTP_POST) {
    String name  = server.arg("name");
    String value = server.arg("value");
    Serial.printf("Received: %s = %s\n", name.c_str(), value.c_str());
    server.send(200, "text/plain", "Saved: " + name + " = " + value);
  } else {
    server.send(405, "text/plain", "Method not allowed");
  }
}

Performance Tips

  • Call server.handleClient() every loop iteration — never put long blocking code in loop() when a web server is running.
  • Use PROGMEM for large HTML — store the page template in flash, not SRAM, with const char html[] PROGMEM = "...".
  • Compress static files — serve pre-gzipped CSS/JS and set the Content-Encoding: gzip header for up to 70% size reduction.
  • For concurrent clients — switch to ESPAsyncWebServer which handles multiple connections without blocking.

Frequently Asked Questions

The built-in WebServer library (for synchronous use) is included with arduino-esp32. For better performance under multiple simultaneous clients, use the ESPAsyncWebServer library available through the Library Manager.
Use mDNS (Multicast DNS). Call MDNS.begin("esp32") and your device will be reachable at http://esp32.local/ from any device on the same network that supports mDNS (all modern operating systems do).
The basic WebServer library is synchronous — it handles one request at a time. ESPAsyncWebServer handles concurrent connections asynchronously. For most home-automation use cases the basic server is sufficient.
Store them in SPIFFS or LittleFS. Use the LittleFS library and ESP32 Sketch Data Upload tool to upload files from a /data folder in your sketch. Serve them with server.serveStatic("/style.css", LittleFS, "/style.css").
Use server.arg("fieldName") inside your route handler. For GET requests the field comes from the URL query string. For POST requests with application/x-www-form-urlencoded it comes from the body.
Yes. The ESPAsyncWebServer library includes AsyncWebSocket support. Create an AsyncWebSocket object, attach it to the server, and push messages with ws.textAll("message") to broadcast to all clients.
Add a basic auth check in your route handler: if (!server.authenticate("admin", "password")) { server.requestAuthentication(); return; }. Use HTTPS for any real security since basic auth sends credentials in clear text.
The most common cause is not calling server.handleClient() frequently enough in loop(). Also check for blocking code (long delays, blocking I/O) that prevents the server from processing incoming requests.
The ESP32 has 520 KB of SRAM. Strings larger than a few KB should be stored in PROGMEM or served from LittleFS rather than held in RAM. For complex UIs, serve the HTML from flash and load data via AJAX/fetch calls.
Yes. Add a /status JSON endpoint and use JavaScript fetch() in the browser to poll it every few seconds. Alternatively use WebSockets or Server-Sent Events for true real-time push updates.

Projects to Build

Put this knowledge to work — try one of these hands-on projects.