Serving a Page from an ESP32

Draft. This tutorial has not been checked against the physical product yet. Confirm wiring, pin assignments and voltage limits against the product documentation before building it.
DifficultyAdvanced
CategoryCommunication
Supported boardsESP32 DevKit, 30-pin
Time needed70 minutes
PrerequisitesESP32, Your First Wi-Fi Project
Tested boardNot yet verified
Tested onNot yet verified
Arduino IDE versionNot yet verified
LibraryNot yet verified

What it does

The board becomes a small web server on your network. Type its address into a phone and you get a page showing a sensor reading, with buttons that switch something on and off.

This is the point at which a project stops needing a laptop. No serial monitor, no app to install, no cable. Anyone on the same Wi-Fi can open it in a browser they already have.

It is also where a few web ideas have to be met, and this page keeps them to the minimum: what a request is, why a page that refreshes itself is a poor idea, and why the obvious way of handling buttons is a bug.

Before you begin

Required parts and tools

  • One ESP32 board, connected to your Wi-Fi as in the previous tutorial.
  • An LED and a 220 ohm resistor on GPIO 4, so there is something to switch.
  • A phone or computer on the same network.

What a request actually is

When a browser opens http://192.168.1.50/, it connects and sends a line of text:

GET / HTTP/1.1

The server replies with a status line, a blank line, and the page. That is genuinely all it is, and the first example does exactly that by hand so the mechanism is visible.

The / is the path, and it is how one server offers several things. /on and /off are paths too, which is how the buttons below work.

Two ways to write this

By hand, with WiFiServer, reading the request as text. More code, no dependencies, and you can see every part of it.

With a library, such as WebServer, which comes with the ESP32 package. You register a function per path and it does the rest.

This page does the first once, to show what is happening, then switches to the second, which is what you should actually use.

Do not put it on the internet

What follows is fine on your home network and is not safe to expose to the internet.

There is no password, no encryption, and no protection against anything. Forwarding a port on your router to this board means anybody who finds it can switch your relay. The code here has no defences and is not trying to have any.

If a project genuinely needs remote access, the right shape is the board making outbound connections to a service, as in the MQTT tutorial, rather than accepting inbound ones.

Pin reference

Pin Purpose Notes
GPIO 4 LED through 220 ohms Any general-purpose pin
GPIO 34 A sensor, optional ADC1, so it survives Wi-Fi
GND Ground
An ESP32 serving a page to a phone over Wi-Fi, with the browser request and the board's reply shown as the two halves of the exchange
The browser sends a path; the board replies with a page. Buttons are just links to different paths.

Wiring steps

  1. Unplug the USB cable.
  2. LED through a 220 ohm resistor from GPIO 4 to GND, long leg to the resistor.
  3. Plug the USB cable back in.

Software and libraries

Both libraries used here come with the ESP32 board package. Nothing to install.

Minimal working example

The long way first, so the exchange is visible.

// Siratek: a web server written out by hand, so you can see the request.

#include <WiFi.h>

const char* ssid     = "your network name";
const char* password = "your password";

WiFiServer server(80);

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);

  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
    delay(500);
  }

  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("Siratek: no Wi-Fi. See the previous tutorial.");
    return;
  }

  server.begin();
  Serial.print("Siratek: open http://");
  Serial.println(WiFi.localIP());
}

void loop() {
  WiFiClient client = server.accept();
  if (!client) {
    return;
  }

  // Read the first line, which holds the method and the path.
  String request = client.readStringUntil('\r');
  Serial.print("request: ");
  Serial.println(request);
  client.readStringUntil('\n');

  client.println("HTTP/1.1 200 OK");
  client.println("Content-Type: text/html");
  client.println("Connection: close");
  client.println();                       // the blank line matters
  client.println("<!DOCTYPE html><html><body>");
  client.println("<h1>Siratek</h1><p>Hello from an ESP32.</p>");
  client.println("</body></html>");

  client.stop();
}

accept(), not available(). Older code and older tutorials write server.available() here, and on ESP32 core 2.x that was the only name. Core 3 renamed it to accept() and left the old name in place as deprecated, so available() still works but the compiler now warns about it. If you are on core 2.x the line must read available(); the Boards Manager tells you which version you have.

The blank line is not optional. It separates the headers from the page, and without it the browser waits for headers that never end and eventually gives up. It is the most common mistake in hand-written servers.

Watch the serial monitor while you load the page. You will usually see two requests: one for the page and one for /favicon.ico, which the browser asks for automatically. That surprises people and is normal.

The useful version

// Siratek: a page showing a reading, with buttons that work.

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

const char* ssid     = "your network name";
const char* password = "your password";

WebServer server(80);

const int ledPin = 4;
const int sensorPin = 34;       // ADC1, so Wi-Fi does not break it

bool ledOn = false;

String page() {
  int reading = analogRead(sensorPin);

  String html = "<!DOCTYPE html><html><head>";
  html += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  html += "<title>Siratek</title>";
  html += "<style>body{font-family:system-ui;margin:2rem;line-height:1.6}";
  html += "a{display:inline-block;padding:.6rem 1.2rem;margin-right:.5rem;";
  html += "background:#0f766e;color:#fff;text-decoration:none;border-radius:6px}</style>";
  html += "</head><body>";
  html += "<h1>Siratek sensor node</h1>";
  html += "<p>Reading: <strong>" + String(reading) + "</strong> of 4095</p>";
  html += "<p>LED is <strong>" + String(ledOn ? "on" : "off") + "</strong></p>";
  html += "<p><a href='/on'>Turn on</a><a href='/off'>Turn off</a></p>";
  html += "</body></html>";

  return html;
}

void handleRoot() {
  server.send(200, "text/html", page());
}

void handleOn() {
  ledOn = true;
  digitalWrite(ledPin, HIGH);
  // Redirect back to the main page, so a refresh does not switch it again.
  server.sendHeader("Location", "/");
  server.send(303);
}

void handleOff() {
  ledOn = false;
  digitalWrite(ledPin, LOW);
  server.sendHeader("Location", "/");
  server.send(303);
}

void setup() {
  pinMode(ledPin, OUTPUT);
  Serial.begin(115200);

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
    delay(500);
  }
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("Siratek: no Wi-Fi.");
    return;
  }

  server.on("/", handleRoot);
  server.on("/on", handleOn);
  server.on("/off", handleOff);
  server.onNotFound([]() {
    server.send(404, "text/plain", "Not found");
  });

  server.begin();
  Serial.print("Siratek: open http://");
  Serial.println(WiFi.localIP());
}

void loop() {
  server.handleClient();        // must be called often
}

Four decisions here are worth more than the rest of the code.

The viewport meta tag. Without it a phone renders the page as if it were a desktop and everything is tiny. One line, and the page is usable on the device people will actually open it on.

The redirect after switching. Without it, /on returns the page directly, the browser’s address bar still says /on, and the next refresh switches it on again. With a user that is merely odd; with a relay it means a reload can re-trigger an action. Redirecting back to / means a refresh only ever reloads the page.

The sensor is on an ADC1 pin. On ADC2 it would read nothing at all once Wi-Fi started, and the sketch would look broken for no visible reason.

server.handleClient() must be called often. Anything that blocks loop() makes the page unresponsive. Use the millis() pattern for periodic work, never delay().

Why not just auto-refresh

The usual next step is a meta refresh tag that reloads the page every few seconds. It works, and it has two costs: the whole page is rebuilt and resent each time, and the page visibly flickers.

The better shape is to serve the number separately and let the browser fetch just that:

server.on("/reading", []() {
  server.send(200, "text/plain", String(analogRead(sensorPin)));
});

Then a few lines of JavaScript in the page update one element:

<script>
setInterval(async () => {
  const r = await fetch('/reading');
  document.getElementById('v').textContent = await r.text();
}, 2000);
</script>

The page loads once. Only the number moves. That is a reasonable amount of web knowledge to pick up for the improvement it gives.

Expected result

An IP address on the serial monitor, and a page at that address from any device on the network, showing the reading and two working buttons.

Open it on a phone. If the text is minute, the viewport tag is missing.

Troubleshooting

Symptom Likely cause Check or fix
Browser hangs, never loads Missing blank line after the headers client.println() with no argument
Page loads once then stops handleClient() not called often Nothing in loop() may block
Reading always 0 or 4095 Sensor on an ADC2 pin Use 32 to 39
Refresh switches the LED again No redirect after the action Send a 303 back to /
Two requests per page load Browser asking for the favicon Normal
available() is deprecated Core 3 renamed it Use accept(); available() on core 2.x
Tiny text on a phone No viewport meta tag Add it
Works on the laptop, not the phone Phone on mobile data or another network Same Wi-Fi
Address stopped working Router gave a new address Reserve it, or use mDNS
Board resets under load Current during transmission Better cable and port
Page slow to respond delay() in loop() Use the millis() pattern

Next steps

  • Easier first: ESP32, Your First Wi-Fi Project.
  • Related: the relay tutorial, if the button should switch something real, and its safety section before you do.
  • Next project: a weather station page, serving a DHT22 reading to any phone in the house.

Sources and attribution

All text, wiring guidance and code in this tutorial are original Siratek work.

  • Arduino-ESP32 documentation for the WiFi, WiFiServer and WebServer interfaces.
  • HTTP’s request line, status codes and header-body separation are defined by the HTTP specification; they are described here in the plainest terms that make the code make sense.

The warning against exposing this server to the internet is a deliberate limitation of scope. No third-party tutorial text, diagrams, photographs or code has been reproduced here.

Product used in this tutorial: ESP32 Development Board WiFi+Bluetooth Ultra-Low
Shop
Sidebar
0 Wishlist
0 Cart