Serve HTML, CSS, JS from Flash on Embedded Web Server
Modern embedded devices often need a built-in web UI for configuration, monitoring, and control. A simple and efficient way to deliver that UI is to run HTML, CSS, and JavaScript directly from flash using an embedded web server. No filesystem, less RAM usage, fewer moving parts.
In this setup, web files are embedded directly into the firmware binary stored on flash. That keeps firmware code and UI contents in sync at all times - no version mismatch, no stale assets, no surprises after updates. To reduce size further, files can be pre-compressed with gzip before embedding. In this guide, we walk through how to store, compress, and deliver web assets efficiently on a microcontroller.
Generating pages in C
The most direct approach is to generate pages right in C code. Mongoose can do that with a minimal HTTP server:
#include "mongoose.h"
static void ev_handler(struct mg_connection *c, int ev, void *ev_data) {
if (ev == MG_EV_HTTP_MSG) {
mg_http_reply(c, 200, "", "%s", "<html>Hello world</html>");
}
}
int main() {
struct mg_mgr mgr;
mg_mgr_init(&mgr);
mg_http_listen(&mgr, "http://0.0.0.0:8000", ev_handler, NULL);
for (;;) {
mg_mgr_poll(&mgr, 100);
}
return 0;
}
The ev_handler() returns a string that acts like an index.html file.
This works for small demos, but it does not scale. Writing HTML inside C strings is painful, and large responses get buffered in RAM. HTML files can easily reach tens or hundreds of kilobytes, which can quickly exhaust memory on smaller microcontrollers. In real systems, you want predictable memory usage and chunked delivery.
Use in-memory filesystem
Mongoose provides file-serving APIs that handle streaming and memory limits. On systems that have a filesystem, such as embedded Linux, it looks like this:
static void ev_handler(struct mg_connection *c, int ev, void *ev_data) {
if (ev == MG_EV_HTTP_MSG) {
mg_http_serve_opts opts = {
.root_dir = "/web_root", // <--- Serve files from /web_root directory
.fs = &mg_fs_std, // <--- Using a standard filesystem
};
mg_http_serve_dir(c, 200, &opts);
}
}
This enable file serving from a directory. The
mg_fs_std is a built-in filesystem API that implements standard C filesystem
interface based on fopen/fclose/fread/fwrite calls.
Microcontrollers usually do not have a filesystem, so we fake one using memory
filesystem mg_fs_mem:
static void ev_handler(struct mg_connection *c, int ev, void *ev_data) {
if (ev == MG_EV_HTTP_MSG) {
static struct mg_mem_file my_files[] = { // <-- Create in-memory files
{"index.html", "hello", 5, 0},
{"style.css", "a {}", 3, 0},
{NULL, 0, 0}, // <--- Terminate with NULL file
};
mg_mem_files = my_files; // <--- Tell Mongoose to use them
mg_http_serve_opts opts = {
.root_dir = "/web_root",
.fs = &mg_fs_mem, // <--- Use in-memory filesystem
};
mg_http_serve_dir(c, 200, &opts);
}
}
Here, files are backed by constant data inside the firmware binary, so they live on flash, not RAM. Mongoose still serves them like real files, with proper handling and controlled memory usage.
The next step is to stop hardcoding strings and use real files.
Hexdumping files
The usual trick is to convert real files into C arrays. For example:
const unsigned char data[] = {0x68, 0x65, 0x6c, 0x6c, 0x6f};
Instead of writing that manually, use a tool that converts files automatically. There are multiple options, but we can recommend these two:
- a standard UNIX utility called
xxd. Works on Linux and Mac - a node.js script pack.js. Works on Windows, Linux, Mac
Hexdumping using xxd
The xxd utility can generate a C array:
$ xxd -i web_root/hello.html
unsigned char web_root_html[] = {
0x68, 0x65, 0x6c, 0x6c, 0x6f
};
unsigned int web_root_html_len = 5;
The issue is that the array is not const, so it ends up in RAM.
A better approach is to use xxd as a filter:
$ cat web_root/index.html | xxd -i > index.html.h
Then wrap it into the const array declaration:
const unsigned char index_html[] = {
#include "index.html.h"
};
And serve it:
static void ev_handler(struct mg_connection *c, int ev, void *ev_data) {
if (ev == MG_EV_HTTP_MSG) {
static struct mg_mem_file my_files[] = {
{"index.html", index_html, sizeof(index_html), 0},
{NULL, 0, 0},
};
mg_mem_files = my_files;
mg_http_serve_opts opts = {
.root_dir = "/web_root",
.fs = &mg_fs_mem,
};
mg_http_serve_dir(c, 200, &opts);
}
}
Because the array is const, it stays in flash.
Hexdumping using pack.js
pack.js is a cross-platform Node.js tool from the Mongoose repository. Compared to xxd:
- Packs multiple files into a single header
- Appends
0, making each chunk a valid NUL-terminated C string - useful for things like TLS PEM data - Generates
mg_packed_filesdefinition, so no manual file table in C - Preserves original modification time - browser cache stays correct after updates
- Uses
const, so data stays in flash instead of RAM
Here how it works:
$ node pack.js web_root/index.html web_root/style.css > fs.c
Generated output:
#include "mongoose.h"
static const unsigned char v0[] = {104,101,108,108,111,10,0};
static const unsigned char v1[] = {97,32,123,125,10,0};
const struct mg_mem_file mg_packed_files[] = {
{"/web_root/index.html", v0, sizeof(v0) - 1, 1774441329},
{"/web_root/style.css", v1, sizeof(v1) - 1, 1774441340},
{NULL, NULL, 0, 0}
};
Use it like this:
static void ev_handler(struct mg_connection *c, int ev, void *ev_data) {
if (ev == MG_EV_HTTP_MSG) {
mg_mem_files = mg_packed_files; // <----- Using pack.js - generated data
mg_http_serve_opts opts = {
.root_dir = "/web_root",
.fs = &mg_fs_mem,
};
mg_http_serve_dir(c, 200, &opts);
}
}
That is the cleanest approach.
Note: these files are represented as constant read-only data, being compiled in the firmware binary. Therefore they reside on flash, and not copied to the RAM. They are part of the firmware binary.
Compressing with gzip
Web UI files grow quickly. Compression helps.
It works like this: when Mongoose is asked to serve file A, it first checks for A.gz - a pre-compressed version. If it exists, Mongoose serves A.gz instead and adds the Content-Encoding: gzip header. This lets you deliver compressed content without adding a compression library or paying any run-time cost.
Final solution: pack.js + gzip
pack.js makes gzip simple - just append ::gzip to the file names in the command line. Combine packing and compression:
$ node pack.js web_root/index.html::gzip web_root/style.css::gzip > fs.c
Now assets are compressed before embedding:
#include "mongoose.h"
static const unsigned char v0[] = {31,139,8,0,0,0,0,0,0,19,203,72,205,201,201,231,2,0,32,48,58,54,6,0,0,0,0};
static const unsigned char v1[] = {31,139,8,0,0,0,0,0,0,19,75,84,168,174,229,2,0,202,111,162,224,5,0,0,0,0};
const struct mg_mem_file mg_packed_files[] = {
{"/web_root/index.html.gz", v0, sizeof(v0) - 1, 1774441329},
{"/web_root/style.css.gz", v1, sizeof(v1) - 1, 1774441340},
{NULL, NULL, 0, 0}
};
Large files shrink significantly, making this approach ideal for real dashboards.
Summary
The clean way to ship HTML, CSS, and JS on a microcontroller is to embed them directly into the firmware binary and serve them from flash.
No filesystem. Predictable memory usage. Firmware and UI always stay in sync. With gzip, even larger UIs fit comfortably.
The next step is adding dynamic APIs on top of this static UI.