Learn how to create an ESP32 web server that displays charts with data loaded from a .csv file saved on a microSD card.
In this project, we’ll build an ESP32 data logger using a BME280 sensor. It takes temperature, humidity, and pressure readings and logs those values with a timestamp to a .cvs file on a microSD card. The ESP32 also hosts a web server with real-time charts that loads the data stored in the .csv file. The ESP32 board will be programmed using Arduino IDE.

You might like reading: Building an ESP32 Web Server: The Complete Guide for Beginners (Arduino IDE).
Project Overview
The following image shows the web page you’ll build for this project.

Here are the key features of this project:
- The ESP32 has a BME280 sensor attached that takes readings every 60 seconds and stores them on a microSD card in a .csv file;
- The ESP32 hosts a web page that displays 3 real-time charts: temperature, humidity, and pressure;
- On the web page, you can download or delete the .csv file to your device;
- The charts can be zoomed in and highlight each value;
- The web page is fully web responsive, so it runs on any screen size.
For simplicity, we’ll store the HTML and CSS for the web page in a variable in the Arduino sketch, but you can use the LittleFS Filesystem if you want separate files.
Prerequisites
Before proceeding with this tutorial, you should have the ESP32 add-on installed in your Arduino IDE:
You might also find helpful reading these other guides:
- ESP32: BME280 Sensor using Arduino IDE
- ESP32: MicroSD Card Module using Arduino IDE
- ESP32 Async Web Server (ESPAsyncWebServer library)
- ESP32 Datalogger: Download Data File via Web Server (Arduino IDE)
Parts Required
For this tutorial, you need the following parts:
- ESP32 development board (read: Best ESP32 development boards)
- MicroSD card module
- MicroSD card
- BME280 sensor module
- Jumper wires
- Breadboard
You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!
Schematic Diagram – ESP32 with BME280 and MicroSD Card
We’re going to use I2C communication with the BME280 sensor module and SPI for the microSD card.
Wire the sensor and microSD card module using the following schematic (or use the tables below to verify the pin assignments).

Connect the BME280 sensor to the ESP32 default I2C pins:
| BME280 | ESP32 |
| SCK (SCL Pin) | GPIO 22 |
| SDI (SDA pin) | GPIO 21 |
The microSD card module communicates using SPI communication protocol. You can connect it to the ESP32 using the default SPI pins.
| MicroSD card module | ESP32 |
| 3V3 | 3.3V |
| CS | GPIO 5 |
| MOSI | GPIO 23 |
| CLK | GPIO 18 |
| MISO | GPIO 19 |
| GND | GND |
The final circuit should look similar to this:

Before proceeding with the tutorial, make sure you format your microSD card as FAT32 (learn more on our microSD card guide). Then, insert the microSD card into the module.
Install Required Libraries in Arduino IDE
For this project, you need to install the following libraries:
- ESPAsyncWebServer by ESP32Async
- AsyncTCP by ESP32Async
- Adafruit_BME280 library
- Adafruit_Sensor library
You can install these libraries in the Arduino Library Manager. Open the Library Manager by clicking the Library icon in the left sidebar.
Search for ESPAsyncWebServer and install the ESPAsyncWebServer by ESP32Async.

Search for AsyncTCP and install the AsyncTCP by ESP32Async.

Search for Adafruit BME280 and install the library:

Finally, search for Adafruit Unified Sensor and install this library.

Arduino Sketch – ESP32 Web Server Charts with Historical Data (load .csv file)
The following code creates a web server that serves a web page that loads the sensor data stored in the microSD card .csv file to real-time charts.
/*
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details at https://RandomNerdTutorials.com/esp32-web-server-charts-historical-data/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <SD.h>
#include <SPI.h>
#include <time.h>
// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";
// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22
// MicroSD Card Module GPIOs
#define SD_CS 5
#define SD_MOSI 23
#define SD_MISO 19
#define SD_SCK 18
// NTP server
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 3600; // Change to your timezone
const int daylightOffset_sec = 0;
// Timer to take new readings
unsigned long previousMillis = 0;
const long interval = 60000; // 60 seconds
// CSV filename
const char* dataFile = "/bme280_log.csv";
// AsyncWebServer Web Server
AsyncWebServer server(80);
// BME280
Adafruit_BME280 bme;
// Get Time
String getTimeStr() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return "00:00:00";
char buf[9]; sprintf(buf, "%02d:%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
return String(buf);
}
// Get Date
String getDateStr() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return "1970-01-01";
char buf[11]; sprintf(buf, "%04d-%02d-%02d", timeinfo.tm_year+1900, timeinfo.tm_mon+1, timeinfo.tm_mday);
return String(buf);
}
// Initialize MicroSD Card and create the .csv file
void initSD() {
SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS)) { Serial.println("SD Card Failed"); return; }
if (!SD.exists(dataFile)) {
File f = SD.open(dataFile, FILE_WRITE);
if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
}
}
// Log BME280 data on the MicroSD Card
void logData() {
float tempC = bme.readTemperature();
float tempF = tempC * 9.0 / 5.0 + 32.0;
float hum = bme.readHumidity();
float press = bme.readPressure() / 100.0F;
File file = SD.open(dataFile, FILE_APPEND);
if (file) {
file.printf("%.2f,%.2f,%.2f,%.2f,%s,%s\n",
tempC, tempF, hum, press, getTimeStr().c_str(), getDateStr().c_str());
file.close();
}
}
void setup() {
Serial.begin(115200);
// Initialize BME280 Sensor
Wire.begin(SDA_PIN, SCL_PIN);
if (!bme.begin(0x76)) {
Serial.println("BME280 not found!");
while(1);
}
// Start the Wi-Fi connection
WiFi.begin(ssid, password);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWi-Fi Connected!");
// Print the ESP32 IP Address
Serial.print("Access ESP32 IP Address: http://");
Serial.println(WiFi.localIP());
// Configure NTP time
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
delay(2000);
// Initialize MicroSD Card
initSD();
// Root URL handler for the mains HTML web page
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>ESP32 Charts with Data Logger</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-zoom.min.js"></script>
<style>
body {font-family: Arial, sans-serif; background:#f4f4f4; margin:0; padding:15px;}
.container {max-width:1300px; margin:auto; background:white; padding:20px; border-radius:10px; box-shadow:0 4px 15px rgba(0,0,0,0.1);}
h1 {text-align:center;}
.controls { text-align:center; margin:20px 0; display:flex; flex-wrap:wrap; justify-content:center; gap:12px; }
.btn { padding:12px 22px; font-size:16px; font-weight:600; border:none; border-radius:8px; cursor:pointer; transition:all 0.2s ease; box-shadow:0 2px 6px rgba(0,0,0,0.1); }
.btn:hover { transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,0.15); }
.btn-blue { background:#2196F3; color:white; }
.btn-green { background:#4CAF50; color:white; }
.btn-red { background:#f44336; color:white; }
.btn-gray { background:#6c777d; color:white; }
.chart-container {position:relative; height:380px; margin-bottom:40px; border:1px solid #ddd; border-radius:8px; padding:15px; touch-action: none;}
.chart-header {display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:10px;}
.toggle-group {display:inline-flex; background:#f1f1f1; border-radius:50px; padding:4px; box-shadow:inset 0 2px 4px rgba(0,0,0,0.1);}
.toggle-btn {padding:8px 24px; font-size:15px; font-weight:600; border:none; border-radius:50px; cursor:pointer; transition:all 0.3s;}
.toggle-btn.active {background:#393b41; color:white; box-shadow:0 2px 6px rgba(33,150,243,0.3);}
.latest-reading { background:#f8f8f8; padding:15px; font-size:16px; border-radius:5px; }
</style>
</head>
<body>
<div class="container">
<h1>ESP32 Charts with Data Logger</h1>
<p style="text-align:center;">New readings are added to your chart automatically, you don't need to refresh the web page.</p>
<div class="controls">
<button class="btn btn-blue" onclick="loadCSVData()">Refresh Charts</button>
<button class="btn btn-green" onclick="downloadCSV()">Download CSV</button>
<button class="btn btn-red" onclick="deleteData()">Delete All Data</button>
<button class="btn btn-gray" onclick="resetZoomAll()">Reset All Zoom</button>
</div>
<div class="chart-header">
<h2>Temperature</h2>
<div class="toggle-group">
<button id="btnC" class="toggle-btn active" onclick="setUnit('C')">°C</button>
<button id="btnF" class="toggle-btn" onclick="setUnit('F')">°F</button>
</div>
</div>
<div class="chart-container"><canvas id="tempChart"></canvas></div>
<h2>Humidity</h2>
<div class="chart-container"><canvas id="humChart"></canvas></div>
<h2>Pressure</h2>
<div class="chart-container"><canvas id="pressChart"></canvas></div>
<h3>Latest Reading</h3>
<pre id="latest" class="latest-reading"></pre>
</div>
<script>
let tempChart, humChart, pressChart;
let allData = [];
let currentUnit = 'C';
function createCharts() {
const zoomOptions = {
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x', drag: { enabled: true } },
pan: { enabled: true, mode: 'x', threshold: 5 }
};
const makeChart = (canvasId, color, yLabel) => {
const chart = new Chart(document.getElementById(canvasId), {
type: 'line',
data: { labels: [], datasets: [{ label: yLabel, borderColor: color, tension: 0.2, data: [] }] },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { title: { display: true, text: 'Timestamp' } },
y: { title: { display: true, text: yLabel } }
},
plugins: { zoom: zoomOptions }
}
});
// Double-click to reset zoom
document.getElementById(canvasId).addEventListener('dblclick', () => {
chart.resetZoom();
});
return chart;
};
tempChart = makeChart('tempChart', '#2ecc71', 'Temperature');
humChart = makeChart('humChart', '#3498db', 'Humidity (%)');
pressChart= makeChart('pressChart', '#8c479d', 'Pressure (hPa)');
}
async function loadCSVData() {
try {
const response = await fetch('/download');
const csvText = await response.text();
allData = parseCSV(csvText);
if (allData.length === 0) {
document.getElementById('latest').textContent = "No data logged yet.";
return;
}
const last = allData[allData.length-1];
document.getElementById('latest').innerHTML =
`Temperature: ${last.temp_c}°C / ${last.temp_f}°F<br>` +
`Humidity: ${last.hum}%<br>` +
`Pressure: ${last.press} hPa<br>` +
`<strong>${last.day} ${last.time}</strong>`;
const displayData = allData.slice(-1200);
const labels = displayData.map(d => d.day + " " + d.time);
const tempValues = displayData.map(d => currentUnit === 'C' ? parseFloat(d.temp_c) : parseFloat(d.temp_f));
tempChart.data.labels = labels;
tempChart.data.datasets[0].label = `Temperature (°${currentUnit})`;
tempChart.data.datasets[0].data = tempValues;
tempChart.update();
humChart.data.labels = labels;
humChart.data.datasets[0].data = displayData.map(d => parseFloat(d.hum));
humChart.update();
pressChart.data.labels = labels;
pressChart.data.datasets[0].data = displayData.map(d => parseFloat(d.press));
pressChart.update();
} catch(e) { console.error("Error:", e); }
}
function parseCSV(csv) {
const lines = csv.trim().split('\n');
const result = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const [temp_c, temp_f, hum, press, time, day] = lines[i].split(',');
result.push({temp_c, temp_f, hum, press, time, day});
}
return result;
}
function setUnit(unit) {
currentUnit = unit;
document.getElementById('btnC').classList.toggle('active', unit === 'C');
document.getElementById('btnF').classList.toggle('active', unit === 'F');
if (allData.length > 0) loadCSVData();
}
function downloadCSV() {
window.location.href = '/download';
}
function resetZoomAll() {
tempChart.resetZoom();
humChart.resetZoom();
pressChart.resetZoom();
}
async function deleteData() {
if (!confirm("Delete all logged data?")) return;
await fetch('/delete', {method: 'POST'});
alert("Data deleted");
loadCSVData();
}
window.onload = () => {
createCharts();
setUnit('C');
loadCSVData();
setInterval(loadCSVData, 30000);
};
</script>
</body>
</html>
)rawliteral";
request->send(200, "text/html; charset=UTF-8", html);
});
// Download .csv file stored on MicroSD Card
server.on("/download", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(SD, dataFile, "text/csv", true);
});
// Delete .csv file stored on MicroSD Card
server.on("/delete", HTTP_POST, [](AsyncWebServerRequest *request){
if (SD.exists(dataFile)) SD.remove(dataFile);
File f = SD.open(dataFile, FILE_WRITE);
if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
request->send(200);
});
server.begin();
Serial.println("ESP32 Web Server: Charts with Historical Data Ready!");
}
void loop() {
// Timer
if (millis() - previousMillis >= interval) {
previousMillis = millis();
logData();
}
delay(10);
}
With the libraries mentioned previously installed in your Arduino IDE, you just need to insert your network credentials in the following lines in the code, and it will work straight away.
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_SSID";
How the Code Works
Continue reading to learn how the code works, or skip to the Demonstration section.
Including Libraries
First, include the required libraries to connect to Wi-Fi, create the web server, read the BME280 sensor, and communicate with the SD Card.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <SD.h>
#include <SPI.h>
#include <time.h>
Network Credentials
Insert your network credentials in the following variables so that the ESP32 connects to your network.
// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_SSID";
GPIOs
Define the GPIOs that will be used for the I2C communication with the BME280 sensor and SPI communication with the microSD card module.
// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22
// MicroSD Card Module GPIOs
#define SD_CS 5
#define SD_MOSI 23
#define SD_MISO 19
#define SD_SCK 18
NTP Server
We’ll request the time from pool.ntp.org, which is a cluster of timeservers that anyone can use to request the time (read our guide ESP32 NTP Client-Server: Get Date and Time).
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 3600; // Change to your timezone
const int daylightOffset_sec = 0;
Timer
This timer will take new readings every 60 seconds; you can increase this number for a real-world application. For testing purposes and to gather more values in a short period of time, it’s set to 60 seconds.
unsigned long previousMillis = 0;
const long interval = 60000; // 60 seconds
CSV File
The file stored on the microSD card has the following name: bme280_log.csv.
const char* dataFile = "/bme280_log.csv";
Creating a Server Object
Create an AsyncWebServer object called server on port 80.
AsyncWebServer server(80);
Creating BME280 Object
The BME280 will read values using I2C communication protocol. You just need to create an Adafruit_BME280 object called bme as follows.
Adafruit_BME280 bme;
setup()
In the setup(), we start by initializing the Serial Monitor and initializing the BME280 sensor.
Serial.begin(115200);
// Initialize BME280 Sensor
Wire.begin(SDA_PIN, SCL_PIN);
if (!bme.begin(0x76)) {
Serial.println("BME280 not found!");
while(1);
}
Start the Wi-Fi connection and print the ESP32 IP address in the Serial Monitor. You’ll need it later to access the web server.
WiFi.begin(ssid, password);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWi-Fi Connected!");
// Print the ESP32 IP Address
Serial.print("Access ESP32 IP Address: http://");
Serial.println(WiFi.localIP());
Configure the time with the settings you’ve defined earlier:
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
Call the function to initialize the microSD card.
initSD();
Here’s the initSD() function:
void initSD() {
SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS)) { Serial.println("SD Card Failed"); return; }
if (!SD.exists(dataFile)) {
File f = SD.open(dataFile, FILE_WRITE);
if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
}
}
Root URL /
Then, handle the web server. When you receive a request on the root (/) URL (this happens when you access the ESP IP address), send the HTML text stored in the html String variable to display the web page:
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
(...)
request->send(200, "text/html; charset=UTF-8", html);
});
The html variable contains the text with the HTML, CSS, and JavaScript to build the web page.
<!DOCTYPE html>
<html>
<head>
<title>ESP32 Charts with Data Logger</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-zoom.min.js"></script>
<style>
body {font-family: Arial, sans-serif; background:#f4f4f4; margin:0; padding:15px;}
.container {max-width:1300px; margin:auto; background:white; padding:20px; border-radius:10px; box-shadow:0 4px 15px rgba(0,0,0,0.1);}
h1 {text-align:center;}
.controls { text-align:center; margin:20px 0; display:flex; flex-wrap:wrap; justify-content:center; gap:12px; }
.btn { padding:12px 22px; font-size:16px; font-weight:600; border:none; border-radius:8px; cursor:pointer; transition:all 0.2s ease; box-shadow:0 2px 6px rgba(0,0,0,0.1); }
.btn:hover { transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,0.15); }
.btn-blue { background:#2196F3; color:white; }
.btn-green { background:#4CAF50; color:white; }
.btn-red { background:#f44336; color:white; }
.btn-gray { background:#6c777d; color:white; }
.chart-container {position:relative; height:380px; margin-bottom:40px; border:1px solid #ddd; border-radius:8px; padding:15px; touch-action: none;}
.chart-header {display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:10px;}
.toggle-group {display:inline-flex; background:#f1f1f1; border-radius:50px; padding:4px; box-shadow:inset 0 2px 4px rgba(0,0,0,0.1);}
.toggle-btn {padding:8px 24px; font-size:15px; font-weight:600; border:none; border-radius:50px; cursor:pointer; transition:all 0.3s;}
.toggle-btn.active {background:#393b41; color:white; box-shadow:0 2px 6px rgba(33,150,243,0.3);}
.latest-reading { background:#f8f8f8; padding:15px; font-size:16px; border-radius:5px; }
</style>
</head>
<body>
<div class="container">
<h1>ESP32 Charts with Data Logger</h1>
<p style="text-align:center;">New readings are added to your chart automatically, you don't need to refresh the web page.</p>
<div class="controls">
<button class="btn btn-blue" onclick="loadCSVData()">Refresh Charts</button>
<button class="btn btn-green" onclick="downloadCSV()">Download CSV</button>
<button class="btn btn-red" onclick="deleteData()">Delete All Data</button>
<button class="btn btn-gray" onclick="resetZoomAll()">Reset All Zoom</button>
</div>
<div class="chart-header">
<h2>Temperature</h2>
<div class="toggle-group">
<button id="btnC" class="toggle-btn active" onclick="setUnit('C')">°C</button>
<button id="btnF" class="toggle-btn" onclick="setUnit('F')">°F</button>
</div>
</div>
<div class="chart-container"><canvas id="tempChart"></canvas></div>
<h2>Humidity</h2>
<div class="chart-container"><canvas id="humChart"></canvas></div>
<h2>Pressure</h2>
<div class="chart-container"><canvas id="pressChart"></canvas></div>
<h3>Latest Reading</h3>
<pre id="latest" class="latest-reading"></pre>
</div>
<script>
let tempChart, humChart, pressChart;
let allData = [];
let currentUnit = 'C';
function createCharts() {
const zoomOptions = {
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x', drag: { enabled: true } },
pan: { enabled: true, mode: 'x', threshold: 5 }
};
const makeChart = (canvasId, color, yLabel) => {
const chart = new Chart(document.getElementById(canvasId), {
type: 'line',
data: { labels: [], datasets: [{ label: yLabel, borderColor: color, tension: 0.2, data: [] }] },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { title: { display: true, text: 'Timestamp' } },
y: { title: { display: true, text: yLabel } }
},
plugins: { zoom: zoomOptions }
}
});
// Double-click to reset zoom
document.getElementById(canvasId).addEventListener('dblclick', () => {
chart.resetZoom();
});
return chart;
};
tempChart = makeChart('tempChart', '#2ecc71', 'Temperature');
humChart = makeChart('humChart', '#3498db', 'Humidity (%)');
pressChart= makeChart('pressChart', '#8c479d', 'Pressure (hPa)');
}
async function loadCSVData() {
try {
const response = await fetch('/download');
const csvText = await response.text();
allData = parseCSV(csvText);
if (allData.length === 0) {
document.getElementById('latest').textContent = "No data logged yet.";
return;
}
const last = allData[allData.length-1];
document.getElementById('latest').innerHTML =
`Temperature: ${last.temp_c}°C / ${last.temp_f}°F<br>` +
`Humidity: ${last.hum}%<br>` +
`Pressure: ${last.press} hPa<br>` +
`<strong>${last.day} ${last.time}</strong>`;
const displayData = allData.slice(-1200);
const labels = displayData.map(d => d.day + " " + d.time);
const tempValues = displayData.map(d => currentUnit === 'C' ? parseFloat(d.temp_c) : parseFloat(d.temp_f));
tempChart.data.labels = labels;
tempChart.data.datasets[0].label = `Temperature (°${currentUnit})`;
tempChart.data.datasets[0].data = tempValues;
tempChart.update();
humChart.data.labels = labels;
humChart.data.datasets[0].data = displayData.map(d => parseFloat(d.hum));
humChart.update();
pressChart.data.labels = labels;
pressChart.data.datasets[0].data = displayData.map(d => parseFloat(d.press));
pressChart.update();
} catch(e) { console.error("Error:", e); }
}
function parseCSV(csv) {
const lines = csv.trim().split('\n');
const result = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const [temp_c, temp_f, hum, press, time, day] = lines[i].split(',');
result.push({temp_c, temp_f, hum, press, time, day});
}
return result;
}
function setUnit(unit) {
currentUnit = unit;
document.getElementById('btnC').classList.toggle('active', unit === 'C');
document.getElementById('btnF').classList.toggle('active', unit === 'F');
if (allData.length > 0) loadCSVData();
}
function downloadCSV() {
window.location.href = '/download';
}
function resetZoomAll() {
tempChart.resetZoom();
humChart.resetZoom();
pressChart.resetZoom();
}
async function deleteData() {
if (!confirm("Delete all logged data?")) return;
await fetch('/delete', {method: 'POST'});
alert("Data deleted");
loadCSVData();
}
window.onload = () => {
createCharts();
setUnit('C');
loadCSVData();
setInterval(loadCSVData, 30000);
};
</script>
</body>
</html>
Number of Data Points
By default, in this example, the charts will display a maximum of 1200 data points. You can modify that in the following line.
const displayData = allData.slice(-1200);
Note that if you increase this value a lot, it can take more time to load all the data to the charts.
Download CSV File /download
This route sends the .csv file that is stored in the microSD card to the connected client (to load the data to create the charts).
server.on("/control", HTTP_GET, [](AsyncWebServerRequest *request) {
if (request->hasParam("state")) {
int state = request->getParam("state")->value().toInt();
digitalWrite(ledPin, state ? HIGH : LOW);
Serial.printf("LED set to %s\n", state ? "ON" : "OFF");
}
request->send(200, "text/plain", "OK");
});
Delete CSV File /delete
This route deletes the .csv file stored in the microSD card.
server.on("/delete", HTTP_POST, [](AsyncWebServerRequest *request){
if (SD.exists(dataFile)) SD.remove(dataFile);
File f = SD.open(dataFile, FILE_WRITE);
if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
request->send(200);
});
Initialize the Server
Finally, call the begin() method on the server object to initialize the server.
server.begin();
loop()
In the loop() function, we have a timer that calls the logData() function to get new readings every interval value (by default it’s every 60 seconds).
if (millis() - previousMillis >= interval) {
previousMillis = millis();
logData();
}
The logData() function takes new BME280 sensor readings, stores them in auxiliary variables with a timestamp from the NTP server. Finally, it appends those readings to the last line of the .csv file.
void logData() {
float tempC = bme.readTemperature();
float tempF = tempC * 9.0 / 5.0 + 32.0;
float hum = bme.readHumidity();
float press = bme.readPressure() / 100.0F;
File file = SD.open(dataFile, FILE_APPEND);
if (file) {
file.printf("%.2f,%.2f,%.2f,%.2f,%s,%s\n",
tempC, tempF, hum, press, getTimeStr().c_str(), getDateStr().c_str());
file.close();
}
}
Demonstration
After inserting your network credentials, upload the code to the ESP32 board. After uploading, open the Serial Monitor at a baud rate of 115200 and press the ESP32 RST button.

The ESP32 IP address will be printed in the Serial Monitor.

Open any browser on your local network and type the ESP32 IP address. The following web page will load.

At the top of the web page, you have 4 buttons:
- Refresh Charts: the charts update in real-time, so you don’t need to use this button. However, in case it fails to load the .csv file from the microSD card, you can use that button;
- Download CSV: downloads the .csv file that is stored on your microSD card;
- Delete All Data: deletes all the data that has been stored in the .csv file;
- Reset All Zoom: you can zoom in into the charts to see the values more clearly, you can use this button to zoom out and get back to the first view.

By default, the temperature chart shows the values in Celsius degrees. There’s a button to switch to Fahrenheit degrees.

With your cursor, you can select a specific section of the chart to zoom in (it also works on devices with touchscreen. You can just pinch to zoom).

Then, that chart will be zoomed in. You can also select the values to see all the information highlighted as illustrated below. In order to zoom out, you can double-click the chart, or use the Reset All Zoom button at the top of the web page.

Next, you have the pressure chart, you can ignore the data, because I was just testing the project for a few days by powering it on/off.

Finally, at the bottom of the web page, it displays just the latest sensor readings.

Wrapping Up
In this tutorial, you learned how to create a project that logs temperature, humidity, and pressure data with timestamps to a .csv file on a microSD card. You learned how to get those values to display charts on a web page served by the ESP32.
If you liked this project, you may also like these other tutorials:
- ESP32 Datalogger: Download Data File via Web Server (Arduino IDE)
- ESP32 Web Server: Display Sensor Readings in Gauges
- ESP32 Web Server: Control Stepper Motor (WebSocket)
- ESP32 Web Server (WebSocket) with Multiple Sliders: Control LEDs Brightness (PWM)
- ESP32 Web Server using Server-Sent Events (Update Sensor Readings Automatically)
If you would like to learn more about building web servers with the ESP32 from scratch, we recommend taking a look at our eBook dedicated to this subject:
Thanks for reading.




