ESP32: serve a real web app from LittleFS instead of string-built HTML
Put HTML, CSS, and JavaScript files on the ESP32's flash with LittleFS and serve them with WebServer. No more escaping quotes inside C++ strings.
Every ESP32 web server tutorial starts the same way: a handleRoot()
function that builds HTML out of String concatenation, with escaped
quotes on every line. It works, and then you want one stylesheet and
one script and 200 lines of escaped quotes later you are debugging a
JavaScript bug through a C++ string literal. There is a better way and
it has been on the chip the whole time: LittleFS, a small filesystem
that lives in the same flash as your program.
The trap I hit: I uploaded my data/ folder, got a 404 on every file,
and spent an hour blaming the server code. The upload had never
happened. The ESP32 Sketch Data Upload tool is a separate menu item
that ships with the filesystem plugin, and if you have never installed
that plugin, the menu item does not exist. Nothing warns you.
What you need
Needed
- ESP32 dev board (any board with 4 MB flash, which is nearly all of them, about $8)
- USB cable
- The files you want to serve (e.g. an
index.html, astyle.css, and a smallapp.jsthat fetches sensor JSON)
Nice to have
- A second ESP32 running the sensor dashboard tutorial as a data source (or just point the fetch at any JSON URL on your network)
- Multimeter and jumper wires, only if you are wiring a real sensor to serve data from the same chip
Wiring
No wiring. This tutorial is all flash and software. The only hardware argument is the flash partition: LittleFS needs a partition table that leaves room for a filesystem, and the default 4 MB layout with “Default 4MB with spiffs” already includes one.
Install
Two installs, both one-time:
- Filesystem upload plugin for Arduino IDE 2.x: download the
esp32fs jar from the ESP32 filesystem uploader GitHub releases,
drop it into your sketchbook
tools/folder (e.g.Documents/Arduino/tools/ESP32FS/tool/esp32fs.jar), restart the IDE. - Library: Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search “LittleFS”, install the ESP32 LittleFS by Lorol (the core also ships one, either works, be consistent).
Then create a folder named data next to your sketch, put your web
files in it, and run: Arduino IDE >> Tools >> ESP32 Sketch Data
Upload. That formats the partition and copies everything in data/
to flash. It takes about 30 seconds and it overwrites the whole
partition every time.
The code
#include <WiFi.h>
#include <WebServer.h>
#include <LittleFS.h>
const char* ssid = "your-wifi-ssid";
const char* password = "your-wifi-password";
WebServer server(80);
// Serve a reading so the front page has something to show.
int readSensor() {
return 22 + random(0, 5); // stand-in for a real sensor
}
void handleRoot() {
File f = LittleFS.open("/index.html", "r");
if (!f) {
server.send(404, "text/plain", "index.html missing. Did you run Tools >> ESP32 Sketch Data Upload?");
return;
}
server.streamFile(f, "text/html");
f.close();
}
void setup() {
Serial.begin(115200);
if (!LittleFS.begin(true)) { // true = format on first boot failure
Serial.println("LittleFS mount failed, stopping.");
while (true) delay(1000);
}
Serial.printf("FS used %u of %u bytes\n", LittleFS.usedBytes(), LittleFS.totalBytes());
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.print("http://");
Serial.println(WiFi.localIP());
server.on("/", HTTP_GET, handleRoot);
server.serveStatic("/style.css", LittleFS, "/style.css");
server.serveStatic("/app.js", LittleFS, "/app.js");
server.on("/api/reading", HTTP_GET, []() {
char buf[32];
snprintf(buf, sizeof(buf), "{\"value\":%d}", readSensor());
server.send(200, "application/json", buf);
});
server.begin();
}
void loop() {
server.handleClient();
}
And a minimal data/index.html to upload:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32 app</title>
</head>
<body>
<h1>Sensor</h1>
<p id="value">loading...</p>
<script src="/app.js" type="module"></script>
</body>
</html>
// data/app.js
setInterval(async () => {
const r = await fetch('/api/reading');
document.getElementById('value').textContent = (await r.json()).value;
}, 2000);
The pattern in one sentence: the C++ side shrinks to routes and data, the browser side becomes normal files you edit with normal tools.
The
data/upload is all-or-nothing. Change one CSS rule and you re-flash the whole partition. For iterative front-end work, serve the page from your laptop during development and only upload when the device must stand alone.
What you learned
- LittleFS puts your web assets in flash, mounted at boot, served by the same WebServer you already know.
streamFile()sends a file without loading it into a String, which matters once the page is bigger than a few KB.serveStatic()maps a URL path to a file and handles content types for you (it is the two-line replacement for every escaped-quote handler).- The data upload tool is a separate step from code upload. Forgetting it produces 404s with perfectly correct server code.
When something breaks
- 404 on every file. The upload never happened. Run Arduino IDE
Tools >> ESP32 Sketch Data Upload and watch for “FS starting” in the output. If that menu item is missing, the plugin jar is not installed.
- Mount fails at boot. The board has no filesystem partition (some boards ship with “Huge App” selected, which eats the whole flash). Arduino IDE >> Tools >> Flash Size, pick 4MB with spiffs, then re-upload both the code and the data.
- Page loads but the CSS does not. The browser cached the old
file, or
serveStatic()points at a path without the leading slash. Hard-refresh (e.g. Ctrl+F5) before debugging the server. - Everything works until you add a 300 KB image. LittleFS is fine
with it, but
server.send()with a String is not. Any asset over a few KB must go throughstreamFile()orserveStatic(), never through a String in RAM.
What to build next
Take the sensor dashboard tutorial and move its HTML into LittleFS,
then add a WebSocket from the WebSocket server tutorial so the page
updates live instead of polling. If your device leaves the house, the
mDNS tutorial gives it a name (e.g. http://planter.local) so nobody
has to memorize an IP.
The point of this tutorial is not the filesystem. It is that your ESP32 projects stop being demos with one ugly page and start being apps people can actually use.