Building a Standalone ESP32 Web Control Panel: AsyncWebServer, LittleFS, and Captive Portal Setup
ESPHome and Home Assistant are the right tool when you want a sensor or switch to show up in a smart-home dashboard. But plenty of projects don't need (or want) a Home Assistant install in the loop — a shop tool controller, a standalone status panel, a one-off gadget you want to control from any phone on the network without setting up an integration. For that, the ESP32 can serve its own control UI directly: a web page with buttons and sliders, served from flash, running entirely on the chip. This guide covers building that with ESPAsyncWebServer, storing the UI in SPIFFS/LittleFS, and adding a captive portal so it's reachable without knowing an IP address.
Why AsyncWebServer Instead of the Built-In WebServer Library
The Arduino-ESP32 core ships a synchronous WebServer class that's fine for the simplest cases, but it blocks the whole request while handling it, which causes stutter if you're also running other tasks (reading sensors, driving a display, handling WiFi events). ESPAsyncWebServer (by me-no-dev, still the de facto standard) handles requests asynchronously against the underlying TCP stack, so a slow client or a large response doesn't stall the rest of your firmware. It also has better support for WebSockets, which matters if you want live-updating values on the page rather than the browser polling on a timer.
Serving a UI from SPIFFS/LittleFS
Rather than embedding HTML as a giant C string (which works but is miserable to edit), store your page as an actual index.html file in the project's data/ folder and upload it to the ESP32's flash filesystem. In PlatformIO this is one command; in Arduino IDE you'll need the LittleFS/SPIFFS upload plugin.
#include <WiFi.h> #include <ESPAsyncWebServer.h> #include <LittleFS.h> AsyncWebServer server(80); void setup() { LittleFS.begin(); WiFi.begin("your-ssid", "your-password"); while (WiFi.status() != WL_CONNECTED) delay(500); server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); server.on("/api/relay", HTTP_POST, [](AsyncWebServerRequest *request){ if (request->hasParam("state", true)) { String state = request->getParam("state", true)->value(); digitalWrite(RELAY_PIN, state == "on" ? HIGH : LOW); } request->send(200, "text/plain", "OK"); }); server.begin(); } void loop() {} // AsyncWebServer runs its own task, no polling needed hereThe pattern that works well: a static HTML/CSS/JS page for the UI, with plain JavaScript fetch() calls hitting small JSON or plain-text API endpoints on the ESP32 for anything that changes (relay state, sensor readings, sliders). Keep the JSON payloads tiny — the ESP32's available heap for a big ArduinoJson document is limited, especially if you're also running WiFi and other peripherals.
Live Updates with WebSockets
For a dashboard that needs to update in real time (a live sensor graph, a status indicator) without hammering the ESP32 with polling requests, add a WebSocket endpoint:
AsyncWebSocket ws("/ws"); void notifyClients(float value) { ws.textAll(String(value)); } void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { if (type == WS_EVT_CONNECT) { Serial.printf("Client %u connected\n", client->id()); } } void setup() { // ... after server setup above ws.onEvent(onWsEvent); server.addHandler(&ws); }Call notifyClients() whenever a sensor value changes and every connected browser tab updates instantly — no polling interval to tune, no wasted requests when nothing has changed.
Captive Portal: Making It Reachable Without Knowing the IP
If the device doesn't have a fixed IP or you don't want to dig through your router's DHCP table every time, a captive portal makes the ESP32's own access point pop up a "sign in to network" page automatically when a phone connects to it — the same UX as a coffee shop WiFi splash page. This is different from using a captive portal for security testing (see our Evil Portal guide for the Flipper Zero, which uses the same underlying technique for a very different purpose) — here we're just using DNS redirection to make setup painless.
#include <DNSServer.h> DNSServer dnsServer; const byte DNS_PORT = 53; void setup() { WiFi.softAP("MyDevice-Setup"); dnsServer.start(DNS_PORT, "*", WiFi.softAPIP()); // ... AsyncWebServer setup as above, serving index.html at "/" } void loop() { dnsServer.processNextRequest(); }The DNS server answers every hostname lookup with the ESP32's own IP, so any HTTP request the phone makes gets redirected to your page, which is what triggers the OS's automatic captive-portal popup. For a device that will live permanently on your home WiFi rather than run its own AP, skip the captive portal and instead set a static IP or reserve one via your router's DHCP settings, then just bookmark the address — simpler and more reliable long-term than mDNS (esp32.local) hostnames, which some networks and devices don't resolve reliably.
Basic Access Protection
A bare AsyncWebServer instance has no authentication at all — anyone on the network can hit its endpoints. For anything beyond a toy project on a trusted home network, add HTTP Basic Auth:
server.on("/api/relay", HTTP_POST, [](AsyncWebServerRequest *request){ if (!request->authenticate("admin", "your-password")) { return request->requestAuthentication(); } // ... handle request });This isn't strong security — it's cleartext over HTTP unless you also add TLS, which is a heavier lift on an ESP32 — but it stops casual access from other devices on a shared or guest network, which is the realistic threat model for most home shop projects.
Once the basic pattern is working, it scales to just about anything: a garage door controller, a shop tool interlock status panel, a standalone print-farm status board. The advantage over ESPHome is full control over the UI and behavior; the trade-off is you're maintaining your own firmware instead of a YAML config, so it's worth reaching for ESPHome first unless you specifically need a custom interface or want to avoid a Home Assistant dependency.