esp32 intermediate 45 min

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.

Code available for: ESP32 ArduinoArduino C
Published Sep 22, 2026

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, a style.css, and a small app.js that 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:

  1. 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.
  2. 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 through streamFile() or serveStatic(), 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.