On this page

Arduino Full Stack Tutorial

Deploying a React app on the ESP32 with Vite and TypeScript

Build a browser interface that switches an LED on and off, then serve both the interface and its HTTP API from an ESP32. During development, Vite serves the React app on your computer and forwards API requests to the board. For deployment, we compile the TypeScript, build the frontend, and embed the resulting files in the firmware as compressed assets.

This tutorial uses aWOT 4.0.0, React, and Vite’s react-ts template. The firmware uses App and closes each connection after processing its request.

The complete project is available in LedBlink. Its README explains how to build and upload the finished application if you want to try it before following the walkthrough.

  1. Development environment
  2. Project setup
  3. Back-end development
  4. Front-end development
  5. Building and deploying the project

1. Development environment

You need an ESP32 development board with a controllable LED, a USB cable, and a Wi-Fi network that both your computer and board can join. This example uses a regular GPIO LED; check your board’s pin and whether the LED is active high or active low. Boards with addressable RGB LEDs need their own LED driver.

Install:

  • Node.js, version 22.12 or newer. Vite requires Node.js 20.19+ or 22.12+; a recent Node.js LTS release meets these requirements.
  • Arduino IDE.
  • The Arduino ESP32 board package. Select your board and serial port in the Arduino IDE.
  • aWOT 4.0.0. Open Library Manager in the Arduino IDE, search for aWOT, select version 4.0.0, and choose Install.

Check your Node.js installation:

node --version
npm --version

The Vite getting started guide explains its Node.js requirements and project templates.

2. Project setup

Create a project directory with separate folders for the React interface and Arduino firmware, then scaffold the interface with Vite:

mkdir led-blink
cd led-blink
mkdir BlinkUI BlinkServer
cd BlinkUI
npm create vite@9.2.1 . -- --template react-ts --no-interactive
npm install

Install the asset converter in BlinkUI:

npm install --save-dev --save-exact awot-scripts@3.1.2

We use the published awot-static command from awot-scripts 3.1.2 to generate assets compatible with aWOT 4. The tool’s version is independent of the library version. Create the firmware manually as shown below so that it uses the installed v4 library.

The relevant files are shown below. File paths in this tutorial are relative to the led-blink project directory. Run npm commands from led-blink/BlinkUI; your terminal is already there after the setup commands above.

led-blink/
├── BlinkUI/
│   ├── .gitignore
│   ├── src/
│   │   ├── App.tsx
│   │   ├── App.css
│   │   ├── index.css
│   │   └── main.tsx        (keep Vite's generated entry point)
│   ├── index.html
│   ├── package.json
│   ├── package-lock.json
│   ├── tsconfig.json
│   ├── tsconfig.app.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
├── BlinkServer/
│   ├── BlinkServer.ino
│   └── StaticFiles.h       (generated when building for the board)
└── .gitignore              (ignores the generated firmware assets)

Keep the generated TypeScript configuration files and commit BlinkUI/package-lock.json with your project. Run npm ci from BlinkUI to install the locked dependencies on another computer. Vite’s TypeScript template includes a build step that checks types before producing the frontend assets.

3. Back-end development

The API uses one byte of plain text to represent the LED state:

RequestBehavior
GET /ledReturn 0 when the LED is off or 1 when it is on.
PUT /led with a body starting with 0 or 1Set the LED and return its new state.
PUT /led with an empty body or any other first byteReturn HTTP 400 without changing the LED.

Create BlinkServer/BlinkServer.ino with this complete sketch. Set your Wi-Fi credentials and the LED pin and polarity for your board:

#include <WiFi.h>
#include <aWOT.h>

const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASSWORD = "YOUR_PASSWORD";

const int LED_PIN = 2;
const bool LED_ACTIVE_LOW = false;

WiFiServer server(80);
App app;
bool ledOn = false;

void writeLed() {
  const bool high = LED_ACTIVE_LOW ? !ledOn : ledOn;
  digitalWrite(LED_PIN, high ? HIGH : LOW);
}

void readLed(Request& req, Response& res) {
  res.print(ledOn ? '1' : '0');
}

void updateLed(Request& req, Response& res) {
  char value;
  if (req.readBytes(&value, 1) != 1 || (value != '0' && value != '1')) {
    res.sendStatus(400);
    return;
  }

  ledOn = value == '1';
  writeLed();
  readLed(req, res);
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  writeLed();

  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print('.');
  }
  Serial.println();
  Serial.print("Open http://");
  Serial.println(WiFi.localIP());

  app.get("/led", readLed);
  app.put("/led", updateLed);
  server.begin();
}

void loop() {
  WiFiClient client = server.available();
  if (client.connected()) {
    app.process(&client);
    client.stop();
  }
}

Request is an Arduino Stream. Its readBytes() method waits for the body byte, so we can check the read succeeded before changing the LED. The handler uses the first byte and ignores any remaining body. Response is a Print: set headers before writing the body with print().

Returning from a handler without calling req.next() finishes route dispatch in v4. app.process() finalizes an ordinary response, and the caller owns the connection. This sketch calls client.stop() after every request; it does not request connection reuse or leave responses open.

Open BlinkServer/BlinkServer.ino in Arduino IDE and upload it. Open Serial Monitor at 115200 baud and record the printed IP address. Replace 192.168.1.227 in the following commands with your board’s address:

curl --fail http://192.168.1.227/led
curl --fail -X PUT -H 'Content-Type: text/plain' --data-binary '1' http://192.168.1.227/led
curl --fail -X PUT -H 'Content-Type: text/plain' --data-binary '0' http://192.168.1.227/led

The first command reads the state. The next two switch the LED on and off. To check validation, this request should return 400 Bad Request and leave the LED unchanged:

curl -i -X PUT --data-binary 'invalid' http://192.168.1.227/led

4. Front-end development

Configure Vite to forward /led requests to the ESP32. Replace BlinkUI/vite.config.ts with the following, using the IP address from Serial Monitor:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: '/',
  server: {
    proxy: {
      '/led': {
        target: 'http://192.168.1.227',
      },
    },
  },
})

This is Vite’s development proxy. Restart the development server after changing the configuration or the board’s IP address. The frontend uses relative URLs such as fetch('/led'): Vite proxies them during development, and they go directly to the ESP32 when the board serves the built app.

Replace BlinkUI/src/App.tsx with:

import { useEffect, useState } from 'react'
import './App.css'

async function requestLed(options?: RequestInit): Promise<boolean> {
  const response = await fetch('/led', { cache: 'no-store', ...options })
  if (!response.ok) {
    throw new Error(`Request failed: HTTP ${response.status}`)
  }

  const state = await response.text()
  if (state !== '0' && state !== '1') {
    throw new Error('The board returned an invalid LED state')
  }
  return state === '1'
}

export default function App() {
  const [ledOn, setLedOn] = useState<boolean | null>(null)
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState('')

  useEffect(() => {
    let ignore = false

    async function loadLed() {
      try {
        const state = await requestLed()
        if (!ignore) setLedOn(state)
      } catch (cause: unknown) {
        if (!ignore) {
          setError(cause instanceof Error ? cause.message : 'Cannot reach the board')
        }
      }
    }

    void loadLed()
    return () => {
      ignore = true
    }
  }, [])

  async function toggleLed() {
    if (ledOn === null || busy) return
    setBusy(true)
    setError('')

    try {
      const state = await requestLed({
        method: 'PUT',
        headers: { 'Content-Type': 'text/plain' },
        body: ledOn ? '0' : '1',
      })
      setLedOn(state)
    } catch (cause: unknown) {
      setError(cause instanceof Error ? cause.message : 'Cannot reach the board')
    } finally {
      setBusy(false)
    }
  }

  return (
    <main>
      <h1>ESP32 LED control</h1>
      <p>
        {ledOn === null
          ? error ? 'LED state unavailable' : 'Reading LED state…'
          : `The LED is ${ledOn ? 'on' : 'off'}.`}
      </p>
      <button
        type="button"
        disabled={ledOn === null || busy}
        onClick={toggleLed}
      >
        {busy ? 'Updating…' : ledOn ? 'Turn off' : 'Turn on'}
      </button>
      {error && <p role="alert">{error}. Check the board connection and reload to retry.</p>}
    </main>
  )
}

TypeScript models the initial state as null until the board responds. We also validate the response at runtime: a successful HTTP status alone does not prove that the body is a valid LED state. The button is disabled while reading the initial state or sending an update, and the display changes after the board confirms the new value. The effect sets an ignore flag during cleanup so a late response or error cannot update state after that effect has been cleaned up, including during React’s development checks.

The API calls use async/await with try/catch for errors. The effect starts an inner async function so its callback can return the cleanup function that sets the flag. The request can finish, but its result is ignored after cleanup.

Replace BlinkUI/src/index.css to remove the template’s page styles:

:root {
  font-family: system-ui, sans-serif;
  color: #16343b;
  background: #f2f7f7;
}

body {
  margin: 0;
}

button {
  font: inherit;
}

Replace BlinkUI/src/App.css with:

main {
  max-width: 30rem;
  margin: 4rem auto;
  padding: 2rem;
  text-align: center;
}

button {
  padding: 0.75rem 1.5rem;
  border: 0;
  border-radius: 0.5rem;
  color: white;
  background: #007d85;
  cursor: pointer;
}

button:disabled {
  opacity: 0.5;
  cursor: wait;
}

[role='alert'] {
  color: #a12323;
}

Keep BlinkUI/src/main.tsx as generated. Replace BlinkUI/index.html with:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ESP32 LED control</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Delete the template assets in BlinkUI/public/ and BlinkUI/src/assets/ to keep unused files out of the deployed bundle. Start Vite from BlinkUI:

npm run dev

Open the local URL printed by Vite, usually http://localhost:5173. You should see the LED’s current state and be able to change it with the button. Edits to the frontend appear through Vite’s development server without uploading the firmware again. If a request fails, check that the board is running and that its address matches the proxy target.

5. Building and deploying the project

Vite writes the production frontend to BlinkUI/dist/. Merge the following entries into the existing BlinkUI/package.json, keeping the template’s dependencies and other scripts. The JSON below shows the relevant fields, rather than a replacement for the entire file:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "build:firmware": "npm run build && awot-static"
  },
  "awot-static": {
    "sources": "./dist",
    "indexFile": "index.html",
    "sketchDir": "../BlinkServer",
    "exclude": ["*.map"]
  }
}

The converter paths are relative to BlinkUI: ./dist contains the frontend build, and ../BlinkServer selects the sibling Arduino sketch directory. Run this command from led-blink/BlinkUI:

npm run build:firmware

The command checks TypeScript, builds the frontend, then generates BlinkServer/StaticFiles.h from BlinkUI/dist/. Check that the converter completes successfully and the header contains the current build before uploading. The generated handlers serve gzip-compressed files with their content type, encoding, and compressed content length. The index is served at /, and Vite’s JavaScript and CSS files are served under /assets/.

Add this include after <aWOT.h> in BlinkServer/BlinkServer.ino:

#include "StaticFiles.h"

In setup(), mount the generated router after the API routes and before starting the server:

app.get("/led", readLed);
app.put("/led", updateLed);
app.use(staticFiles());
server.begin();

Mount the router once during setup. The base: '/' setting in BlinkUI/vite.config.ts keeps asset URLs aligned with these routes. This app has one page at /; adding client-side routes later also requires handling those paths in the firmware.

Compile and upload BlinkServer/BlinkServer.ino again, then open the board’s address, such as http://192.168.1.227/. You can now close Vite: the ESP32 serves both the interface and the API. Verify that / displays the app, /led returns its state, and the button still switches the physical LED.

You can also inspect the compressed index response from the command line:

curl --fail --compressed -i http://192.168.1.227/

After changing the frontend, run npm run build:firmware from BlinkUI and upload again. After changing only firmware code, compile and upload the sketch. The built web assets consume program flash along with the library and sketch; check the Arduino compiler’s size report against your board’s application partition.

Create .gitignore in the led-blink project directory, beside BlinkUI and BlinkServer, and add:

/BlinkServer/StaticFiles.h

Keep Vite’s generated BlinkUI/.gitignore for frontend build output and dependencies. Keep the source files and BlinkUI/package-lock.json so you can rebuild the assets when needed.