blog.hirnschall.net
home

Contents

Subscribe for New Projects

Introduction

The ESP32 is Espressif's successor to the ESP8266. Faster, more capable, and with both WiFi and Bluetooth built in. Like the ESP8266, it can be programmed using the Arduino IDE and is a great choice for IoT projects that need a lightweight web interface. This post covers setting up a web server on the ESP32 using the WebServer.h library, which shares almost identical API with the ESP8266WebServer.h library covered in this post. If you are already familiar with the ESP8266 version, the transition is straightforward.

This post is part of a complete ESP32 reference you can find here.

The WebServer Library

The ESP32 Arduino core ships with the WebServer.h library. No additional installation is required. The equivalent library for the ESP8266 is ESP8266WebServer.h. Both share the same API — the only difference is the include and the class name. This means that porting code between the two platforms is usually a matter of changing a single line.

// ESP32
#include <WebServer.h>
WebServer server(80);

// ESP8266
#include <ESP8266WebServer.h>
ESP8266WebServer server(80);

The argument passed to the constructor is the port the server listens on. Port 80 is the default HTTP port and means you can access the server in a browser without specifying a port number explicitly.

WiFi Setup

Before starting the web server, the ESP32 must connect to a WiFi network. Include WiFi.h and call WiFi.begin() with your network credentials. A simple blocking connection loop is good for most projects. We wait until WiFi.status() == WL_CONNECTED which means the connection is done.

#include <WiFi.h>

const char* ssid     = "MyWiFi";
const char* password = "MyPassword";

void setup() {
  Serial.begin(115200);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println();
  Serial.print("Connected. IP: ");
  Serial.println(WiFi.localIP());
}

Once connected, WiFi.localIP() returns the IP address assigned by your router. We will use this address to access the web server from a browser on the same network.

Basic Server Setup - server.begin()

We instantiate the server at the global scope so it is accessible from both setup() and loop(). We then call server.begin() inside setup() after the WiFi connection is established to start the server. In the loop() function, we call server.handleClient() to process incoming requests.

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

const char* ssid     = "MyWiFi";
const char* password = "MyPassword";

WebServer server(80);

void setup() {
  Serial.begin(115200);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println();
  Serial.print("Connected. IP: ");
  Serial.println(WiFi.localIP());

  server.begin();
  Serial.println("HTTP server started");
}

void loop() {
  server.handleClient();
}

At this point the server is running but has no routes registered. So, if we try to access it, we will get no response. The server simply does not know what to do yet. The next step is to register handler functions for the paths we want to serve.

Registering Routes - server.on()

Routes

Let's say the ESP32 has the IP address 192.168.0.100. When we navigate to http://192.168.0.100/ in the browser we access the root path /. If we however access http://192.168.0.100/status, we access the path (route) /status.

Handler Registration

What we need to do is tell the server which function we want to call for each path/route that is accessed. This is done with the server.on() function. The first argument is the path, the second is the handler function that should be called when that path is accessed.

We register the routes inside setup() before calling server.begin().

In the example below we register handleRoot to be called whenever http://192.168.0.100/ is accessed and handleStatus to be called whenever http://192.168.0.100/status is accessed.

Note that both handleRoot and handleStatus are functions that need to be defined before we use them. We discuss the handler itself in the next section.

server.on("/", handleRoot);
server.on("/status", handleStatus);
server.begin();

We can also register a route inline using a lambda function, which can be convenient for short handlers. This is shown below:

server.on("/hello", []() {
  server.send(200, "text/plain", "hello from esp32!");
});

The first argument to server.on() is the path, the second is the handler. For named functions, the handler is a function pointer with no arguments and no return value.

Handler Functions

Inside the handler function we use server.send() to send a response to the client. The three arguments are the HTTP status code, the content type, and the response body.

If everything went well, we send 200 (OK) status code, if the request is not found, we send 404 (Not Found) and so on.

void handleRoot() {
  server.send(200, "text/plain", "hello from esp32!");
}

To serve HTML instead of plain text, change the content type to text/html and pass an HTML string as the body. For json we can use application/json.

void handleRoot() {
  String html = "<!DOCTYPE html><html>";
  html += "<head><title>ESP32</title></head>";
  html += "<body><h1>Hello from ESP32</h1></body>";
  html += "</html>";
  server.send(200, "text/html", html);
}

For longer HTML pages, building the string with concatenation is tedious. A common pattern is to use a raw string literal, which allows multi-line strings without escaping.

void handleRoot() {
  const char* html = R"(
    <!DOCTYPE html>
    <html>
    <head><title>ESP32</title></head>
    <body>
      <h1>Hello from ESP32</h1>
    </body>
    </html>
  )";
  server.send(200, "text/html", html);
}

Handling 404 - server.onNotFound()

By default, unregistered paths return an empty response. It is good practice to register a onNotFound handler that returns a proper 404 response. This is also useful during development for debugging — the handler below echoes back the requested URI, method, and any arguments sent with the request.

void handleNotFound() {
  String message = "File Not Found\n\n";
  message += "URI: ";
  message += server.uri();
  message += "\nMethod: ";
  message += (server.method() == HTTP_GET) ? "GET" : "POST";
  message += "\nArguments: ";
  message += server.args();
  message += "\n";
  for (uint8_t i = 0; i < server.args(); i++) {
    message += " " + server.argName(i) + ": " + server.arg(i) + "\n";
  }
  server.send(404, "text/plain", message);
}

We register it with server.onNotFound() before calling server.begin().

server.onNotFound(handleNotFound);

Handling Requests - server.handleClient()

The web server is non-blocking. It does not run in a background thread — instead, server.handleClient() must be called regularly in loop() to process incoming requests. Each call checks for a new client connection, reads the request, calls the appropriate handler, and sends the response.

void loop() {
  server.handleClient();
}

If loop() is busy with other work like reading sensors, writing to a database, driving outputs, make sure server.handleClient() still gets called frequently enough. Long blocking operations in loop() will make the web server unresponsive. A common pattern is to move slow work to a timer and keep loop() as fast as possible.

void loop() {
  server.handleClient();

  static unsigned long lastRead = 0;
  if (millis() - lastRead >= 1000) {
    // read sensors, do slow work
    lastRead = millis();
  }
}

Serving Sensor Data as JSON

A common use case is to expose sensor data as a JSON endpoint so that a browser or other client can fetch the data asynchronously. As mentioned above, the content type for JSON is application/json.

float temperature = 0.0;
float humidity    = 0.0;

void handleData() {
  String json = "{";
  json += "\"temperature\":" + String(temperature, 2) + ",";
  json += "\"humidity\":"    + String(humidity, 2);
  json += "}";
  server.send(200, "application/json", json);
}

We register the route the same way as we did for the other handlers before.

server.on("/data", handleData);

We can now access the latest sensor data by accessing http://192.168.0.100/data assuming the ESP32 has the IP address 192.168.0.100.

If we have multiple sensors or want to check connectivity before reading, we can guard the response with a validity check and return error codes as appropriate. So, if the sensor data is not ready, we send a 503 (Service Unavailable) status code with the message "sensor not ready". If the data is ready, we send the JSON response with the sensor data as before.

void handleData() {
  if (!sensorReady) {
    server.send(503, "application/json", "{\"error\":\"sensor not ready\"}");
    return;
  }

  String json = "{";
  json += "\"temperature\":" + String(temperature, 2) + ",";
  json += "\"humidity\":"    + String(humidity, 2);
  json += "}";
  server.send(200, "application/json", json);
}

GET Arguments

GET arguments are parameters passed in the URL. For example, http://192.168.0.100/data?sensor=0 passes the argument sensor with the value 0. This is useful if we want to serve different data depending on what the client requests.

Inside a handler, we can check if an argument is present using server.hasArg() and get its value using server.arg().

void handleData() {
  if (!server.hasArg("sensor")) {
    server.send(400, "text/plain", "Missing argument: sensor");
    return;
  }

  int sensorId = server.arg("sensor").toInt();

  String json = "{";
  json += "\"sensor\":" + String(sensorId) + ",";
  json += "\"temperature\":" + String(readTemperature(sensorId), 2);
  json += "}";
  server.send(200, "application/json", json);
}

We can now request data for a specific sensor by accessing http://192.168.0.100/data?sensor=0 or http://192.168.0.100/data?sensor=1 and so on.

Multiple arguments can be passed at once by separating them with &, e.g. http://192.168.0.100/data?sensor=0&unit=celsius. Each can be accessed individually using server.arg().

int sensorId  = server.arg("sensor").toInt();
String unit   = server.arg("unit");

mDNS - Access by Hostname

A much nicer way to access the ESP32 than by its IP address is to use mDNS. Here we register a local domain for the ESP32 that does not change regardless of the IP address assigned by the router. This way we can access the ESP32 at e.g. http://esp32.local instead of using the IP.

For this we can include ESPmDNS.h and call MDNS.begin() with your chosen hostname after the WiFi connection is established. We can then access our server at http://hostname.local as long as we are in the same network.

#include <ESPmDNS.h>

if (MDNS.begin("esp32")) {
  Serial.println("mDNS responder started");
}

Full Example

The following is a complete minimal example combining everything covered above. It connects to WiFi, registers a root handler that serves an HTML page, a /data endpoint that returns sensor readings as JSON, a 404 handler, and starts the server with mDNS.

In the code below, we use a bit of javascript to update the displayed sensor data every 2 seconds by fetching the /data endpoint. This way we can always display the latest sensor readings without needing to refresh the page. This is prurely optional.

#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>

const char* ssid     = "MyWiFi";
const char* password = "MyPassword";

WebServer server(80);

float temperature = 23.5;
float humidity    = 55.0;

void handleRoot() {
  const char* html = R"(
    <!DOCTYPE html>
    <html>
    <head>
      <title>ESP32</title>
    </head>
    <body>
      <h1>ESP32 Web Server</h1>
      <p id="data">Loading...</p>
      <script>
        setInterval(() => {
          fetch('/data')
            .then(r => r.json())
            .then(d => {
              document.getElementById('data').textContent =
                'Temperature: ' + d.temperature + ' C, Humidity: ' + d.humidity + ' %';
            });
        }, 2000);
      </script>
    </body>
    </html>
  )";
  server.send(200, "text/html", html);
}

void handleData() {
  String json = "{";
  json += "\"temperature\":" + String(temperature, 2) + ",";
  json += "\"humidity\":"    + String(humidity, 2);
  json += "}";
  server.send(200, "application/json", json);
}

void handleNotFound() {
  String message = "File Not Found\n\n";
  message += "URI: ";
  message += server.uri();
  message += "\nMethod: ";
  message += (server.method() == HTTP_GET) ? "GET" : "POST";
  message += "\nArguments: ";
  message += server.args();
  message += "\n";
  for (uint8_t i = 0; i < server.args(); i++) {
    message += " " + server.argName(i) + ": " + server.arg(i) + "\n";
  }
  server.send(404, "text/plain", message);
}

void setup() {
  Serial.begin(115200);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Connected. IP: ");
  Serial.println(WiFi.localIP());

  if (MDNS.begin("esp32")) {
    Serial.println("mDNS responder started");
  }

  server.on("/", handleRoot);
  server.on("/data", handleData);
  server.onNotFound(handleNotFound);
  server.begin();
  Serial.println("HTTP server started");
}

void loop() {
  server.handleClient();

  static unsigned long lastRead = 0;
  if (millis() - lastRead >= 1000) {
    // replace with actual sensor reads
    // temperature = sensor.readTemperature();
    // humidity    = sensor.readHumidity();
    lastRead = millis();
  }
}

This post is part of multiple articles on Arduino and ESP Projects.

Below are two related posts you might like:

Useless Box with Multiple Switches (Arduino Controlled)

Useless Box with Multiple Switches (Arduino Controlled)

Build a multi-switch useless box with a single moving arm, custom PCB, and 3D-printed mounting — full Arduino code and STL files included.

DIY Motorized Camera Slider

DIY Motorized Camera Slider

Build a WiFi-controlled DSLR camera slider for under $100 with 3D-printed parts — full BOM, wiring, and control code included.

Get Notified of New Articles

Subscribe to get notified about new projects. Our Privacy Policy applies.
Sebastian Hirnschall
Article by: Sebastian Hirnschall
Updated: 11.04.2026

License

This project (with exceptions) is published under the CC Attribution-ShareAlike 4.0 International License.

Amazon Links

1: As an Amazon Associate I earn from qualifying purchases.