# [ESP32 Web Server — WebServer.h Guide with JSON & mDNS](https://blog.hirnschall.net/esp32-webserver/)

author: [Sebastian Hirnschall](https://blog.hirnschall.net/about/)

meta description: Set up an ESP32 web server — register routes, serve HTML and JSON, read GET arguments, and access via mDNS. Full working example included.

meta title: ESP32 Web Server — WebServer.h Arduino Code Example

date published: 11.04.2026 (DD.MM.YYYY format)
date last modified: 11.04.2026 (DD.MM.YYYY format)

---

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](https://blog.hirnschall.net/esp8266-webserver/). 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.](https://blog.hirnschall.net/esp32/)

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();
  }
}
```