Serving a Page from an ESP32
| Difficulty | Advanced |
|---|---|
| Category | Communication |
| Supported boards | ESP32 DevKit, 30-pin |
| Time needed | 70 minutes |
| Prerequisites | ESP32, Your First Wi-Fi Project |
| Tested board | Not yet verified |
| Tested on | Not yet verified |
| Arduino IDE version | Not yet verified |
| Library | Not 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 |
Wiring steps
- Unplug the USB cable.
- LED through a 220 ohm resistor from
GPIO 4toGND, long leg to the resistor. - 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,WiFiServerandWebServerinterfaces. - 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.

