How to Control a Servo Motor with ESP32 Over Wi-Fi

Servos are versatile motors that can be precisely positioned to a specific angle, making them a perfect fit for a wide variety of projects. If you’re new to working with servos and ESP32s, we recommend checking out our previous tutorial where we covered the basics of controlling a servo with an ESP32.

In this tutorial, we’re going to take it a step further. We’ll leverage the ESP32’s Wi-Fi capabilities to create a user-friendly web interface. This interface will allow you to adjust the servo’s position remotely from any device with a web browser. This opens up possibilities for projects like robotic arms, smart home automation (for tasks like opening and closing windows, blinds, or pet feeders), and lifelike animatronics with remotely controllable movements.

By the end of this tutorial, you’ll have a basic understanding of how to combine the power of the ESP32’s Wi-Fi capabilities with the precision of servo motors to create exciting and practical projects.

Let’s get started!

Things You Will Need

For this project, you will need the following items:

  • ESP32 Development Board: This will serve as the brains of your project, handling the web interface and servo control.
  • Servo Motor: You can use any standard servo motor, such as the popular SG90 or MG996R models.
  • Breadboard and Jumper Wires: For making connections between the ESP32, servo, and power supply.
  • Computer with Arduino IDE: You’ll use this to write and upload the code to your ESP32.
  • (Optional) 5V Power Supply: If your servo requires more power than the ESP32 can provide, you can use an external power supply.

Connecting the Servo Motor to the ESP32

Now, let’s connect the hardware.

For this experiment, we’ll use an SG90 Micro Servo Motor. This small but powerful motor runs on 5V (can work between 4.8V to 6V DC) and can rotate up to 180 degrees—90 degrees in each direction.

It’s important to note that when the servo is sitting still, it only needs about 10mA of current. But when it’s moving, it needs much more—between 100mA and 250mA. This means that for most simple projects, we can power the servo directly from the ESP32’s VIN pin. But if your servo needs more than 250mA, you should use a separate power supply to avoid damaging your ESP32.

To connect the SG90 servo motor to the ESP32:

  • Connect the red wire to the VIN pin on the ESP32.
  • Connect the black or brown wire to the GND (ground) pin.
  • Connect the orange or yellow wire to GPIO13 on the ESP32. Actually, you can use any ESP32 GPIO, as each one is capable of producing a PWM signal. However, it is advisable to avoid using GPIOs 9, 10, and 11 because they are connected to the integrated SPI flash and are not recommended for other purposes. Please refer to the ESP32 Pinout Reference for more information.

Here’s a quick reference table for the pin connections:

Servo MotorESP32
5VVIN
GNDGND
ControlGPIO13

Please refer to the image below to see the proper wiring setup.

connecting servo motor to esp32

Setting Up the Arduino IDE

We will be using the Arduino IDE to program the ESP32, so please ensure you have the ESP32 add-on installed before you proceed:

Library Installation

The Arduino IDE comes with a built-in Servo library for controlling servo motors, but unfortunately it’s not compatible with the ESP32. However, there are several libraries available specifically for the ESP32, many of which emulate the Arduino Servo library while adding extra functionality.

For this tutorial, we’ve chosen to use the ESP32Servo Library by Kevin Harrington. This library not only replicates the original Arduino Servo library’s features but also introduces some additional capabilities.

To install the library:

  1. First open your Arduino IDE program. Then click on the Library Manager icon on the left sidebar.
  2. Type “ESP32Servo” in the search box to filter your results.
  3. Look for the “ESP32Servo” library created by Kevin Harrington.
  4. Click the Install button to add it to your Arduino IDE.
esp32servo library installation

Example Code

The code below sets up a simple web server on your ESP32. When you connect to it using a web browser, you’ll see a webpage with a slider. When this slider is manipulated, the browser issues an AJAX GET request to the ESP32. The ESP32, in turn, interprets the request, determines the desired position, and sends the corresponding PWM signal to the servo, effectively adjusting its angle.

Copy the code below to your Arduino IDE. But before you upload the code, double-check that your Wi-Fi network’s name (SSID) and password are correctly entered in the code.

/*Put your SSID & Password*/
const char* ssid = "YourNetworkName";   // Enter SSID here
const char* password = "YourPassword";  //Enter Password here

After making these changes, go ahead and upload the code.

#include <WiFi.h>
#include <WebServer.h>
#include <ESP32Servo.h>

/*Put your SSID & Password*/
const char* ssid = "YourNetworkName";   // Enter SSID here
const char* password = "YourPassword";  //Enter Password here

Servo myservo;  // create servo object to control a servo

// Define servo pin
const int servoPin = 13;  // Change this to your actual servo pin

WebServer server(80);

void setup() {
  // Allow allocation of all timers for servo library
  ESP32PWM::allocateTimer(0);
  ESP32PWM::allocateTimer(1);
  ESP32PWM::allocateTimer(2);
  ESP32PWM::allocateTimer(3);

  // Set servo PWM frequency to 50Hz
  myservo.setPeriodHertz(50);

  // Attach to servo and define minimum and maximum positions
  myservo.attach(servoPin, 500, 2400);

  // Start serial
  Serial.begin(115200);

  Serial.println("Connecting to ");
  Serial.println(ssid);

  //connect to your local wi-fi network
  WiFi.begin(ssid, password);

  //check wi-fi is connected to wi-fi network
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.print(".");
  }
  Serial.println("");
  Serial.println("WiFi connected..!");
  Serial.print("Got IP: ");
  Serial.println(WiFi.localIP());

  server.on("/", handle_OnConnect);
  server.onNotFound(handle_NotFound);

  server.begin();
  Serial.println("HTTP server started");
}

void loop() {
  server.handleClient();
}

void handle_OnConnect() {
  int servoPos;

  // Check if value parameter exists
  if (server.hasArg("value")) {
    String valueString = server.arg("value");
    servoPos = valueString.toInt();

    // Send a simple response for AJAX requests
    server.send(200, "text/plain", "OK");
  } else {
    // Move servo to 90 deg initially
    servoPos = 90;

    // Send the main webpage
    server.send(200, "text/html", createHTML());
  }

  // Move servo into position
  myservo.write(servoPos);

  // Print value to serial monitor
  Serial.print("Servo position: ");
  Serial.println(servoPos);
}

void handle_NotFound() {
  server.send(404, "text/plain", "Not found");
}

String createHTML() {
  String html = R"rawliteral(
  <!DOCTYPE html>
  <html>
  <head>
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <style>
          html {font-family: sans-serif; text-align: center;}
          h1 {margin: 80px auto 40px; font-weight: 300; font-size: 2.5em;}
          .slider-container {padding-top: 30px; margin: 40px auto; max-width: 500px;}
          .field {display: flex; justify-content: space-between; align-items: center; gap: 20px; margin: 20px 0;}
          .field .value {font-size: 18px; width: 50px;}
          .slider {appearance: none; width: 100%; height: 8px; border-radius: 25px; background: rgba(0, 0, 0, 0.1); outline: none; position: relative; cursor: pointer;}
          .slider-input {position:relative; width: 100%; height: 18px;}
          .slider::-webkit-slider-thumb {appearance: none; width: 28px; height: 28px; border-radius: 50%; background: #fff; cursor: pointer; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 0 3px rgba(255, 255, 255, 0.2); transition: all 0.2s ease; position: relative;}
          .slider::-webkit-slider-thumb:hover {transform: scale(1.1);}
          .position-display {padding: 20px;}
          .position-value {font-size: 2.5em;}
          .slider-fill {position: absolute; height: 8px; background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); border-radius: 25px; pointer-events: none; top: 7px; margin: 0 2px;}
      </style>
  </head>
  <body>
      <h1>Servo Control</h1>
      
      <div class="slider-container">
          <div class="field">
              <div class="value">0°</div>
              <div class="slider-input">
                  <div class="slider-fill" id="sliderFill"></div>
                  <input type="range" min="0" max="180" class="slider" id="servoSlider" onchange="servo(this.value)" value="90"/>
              </div>
              <div class="value">180°</div>
          </div>
          
          <div class="position-display">
              <div>Current Position</div>
              <div class="position-value"><span id="servoPos">90</span>°</div>
          </div>
      </div>
      
      <script>
          // Get DOM elements
          var slider = document.getElementById("servoSlider");
          var servoP = document.getElementById("servoPos");
          var sliderFill = document.getElementById("sliderFill");
          
          // Initialize display
          servoP.innerHTML = slider.value;
          updateSliderFill();
          
          // Update display when slider moves
          slider.oninput = function() {
              servoP.innerHTML = this.value;
              updateSliderFill();
          }
          
          // Function to update the visual fill of the slider
          function updateSliderFill() {
              var percentage = (slider.value / 180) * 100;
              sliderFill.style.width = percentage + '%';
          }
          
          // Function to send servo position to ESP32
          function servo(pos) {
              // Create XMLHttpRequest object
              var xhr = new XMLHttpRequest();
              
              // Set timeout
              xhr.timeout = 1000;
              
              // Handle timeout
              xhr.ontimeout = function() {
                  console.log('Request timed out');
              };
              
              // Handle errors
              xhr.onerror = function() {
                  console.log('Request failed');
              };
              
              // Send GET request to ESP32
              xhr.open('GET', '/?value=' + pos, true);
              xhr.send();
          }
      </script>
  </body>
  </html>
)rawliteral";

  return html;
}

Demonstration

After uploading the sketch, open the Serial Monitor and make sure the baud rate is set to 115200. Then press the EN (reset) button on your ESP32. If everything is working correctly, the ESP32 will connect to your Wi-Fi network and print out a message that says “WiFi connected..!” along with the IP address that your router assigned to your ESP32. You’ll also see “HTTP server started” printed in the Serial Monitor.

esp32 web server station mode serial monitor output server started

Now, grab a device like your phone or laptop that’s connected to the same Wi-Fi network as your ESP32. Open a web browser (like Chrome or Firefox) and type in the IP address that appeared in the Serial Monitor. Hit enter, and you should see a web page pop up! This page will display a slider.

esp32 servo web server web page

Go ahead and try moving the slider. Your servo motor should mimic the slider’s movement!

Code Explanation

We begin by including three important libraries: WiFi.h, WebServer.h, and ESP32Servo.h. The WiFi.h library helps the ESP32 connect to a Wi-Fi network. The WebServer.h library allows the ESP32 to work as a web server so it can handle incoming webpage requests from a browser. The ESP32Servo.h library is used to control servo motors on the ESP32, since the standard Arduino Servo library doesn’t work on the ESP32 out of the box.

#include <WiFi.h>
#include <WebServer.h>
#include <ESP32Servo.h>

Since we’re making the ESP32 connect to your existing Wi-Fi, we define two variables to hold your network name (SSID) and password. Make sure to update these with your own network details so the ESP32 can connect.

/* Put your SSID & Password */
const char* ssid = "YourNetworkName";   // Enter SSID here
const char* password = "YourPassword";  //Enter Password here

We then create a Servo object named myservo. This object will be responsible for sending position commands to the actual servo motor connected to the ESP32. Next, we define the GPIO pin to which the servo motor is physically connected. In this case, it’s set to pin 13, though you can change it depending on your setup.

Servo myservo;  // create servo object to control a servo

// Define servo pin
const int servoPin = 13;  // Change this to your actual servo pin

After that, we instantiate a WebServer object named server and tell it to listen for HTTP requests on port 80, which is the default port for web traffic. This means when you visit the ESP32’s IP address in a browser, you won’t need to add any port number.

// declare an object of WebServer library
WebServer server(80);

Inside the Setup() Function

Inside the setup() function, we begin by allocating timers for the servo library. This is necessary because the ESP32 uses hardware timers to produce PWM signals that servos understand. By allocating timers explicitly, we make sure the library can control the servo correctly without conflict from other operations.

// Allow allocation of all timers for servo library
ESP32PWM::allocateTimer(0);
ESP32PWM::allocateTimer(1);
ESP32PWM::allocateTimer(2);
ESP32PWM::allocateTimer(3);

We then set the PWM signal’s frequency to 50 Hz. This is the standard frequency expected by most hobby servo motors. You usually don’t need to change this unless your servo has very specific requirements.

// Set servo PWM frequency to 50Hz
myservo.setPeriodHertz(50);

Next, we attach the servo object to the pin we defined earlier using myservo.attach(). We also specify the minimum and maximum pulse widths in microseconds—500 µs and 2400 µs in this case—which correspond roughly to the 0-degree and 180-degree positions of the servo arm. These pulse widths may need tuning based on the specific servo model you’re using.

// Attach to servo and define minimum and maximum positions
myservo.attach(servoPin, 500, 2400);

After that, we initiate a connection to the Wi-Fi network by calling WiFi.begin() with the SSID and password we provided. While the connection is being established, we keep checking the connection status in a loop. We wait one second between checks and print a dot to indicate progress. Once the ESP32 is connected successfully, we print a confirmation message and also display the IP address it received from the router. This IP address is important because you’ll use it to access the webpage hosted by the ESP32.

Serial.println("Connecting to ");
Serial.println(ssid);

//connect to your local wi-fi network
WiFi.begin(ssid, password);

//check wi-fi is connected to wi-fi network
while (WiFi.status() != WL_CONNECTED) {
  delay(1000);
  Serial.print(".");
}
Serial.println("");
Serial.println("WiFi connected..!");
Serial.print("Got IP: ");
Serial.println(WiFi.localIP());

With the network setup done, we move on to configuring the web server. We define what should happen when someone visits a specific web address. We do this using the server.on() function. This method lets us say: “When someone visits this web address, run that specific piece of code.” For example, when someone visits the root URL (represented by /), the ESP32 will run a function called handle_OnConnect().

server.on("/", handle_OnConnect);

But what if someone accidentally types in a wrong or random web address that doesn’t exist? To handle that, we use server.onNotFound(). This tells our ESP32 that if it can’t find the page someone is asking for, it should show a “404 Not Found” error message, which is a standard way of saying “Page Not Found”.

server.onNotFound(handle_NotFound);

Finally, we call server.begin() to tell the web server to start listening for incoming requests.

server.begin();
Serial.println("HTTP server started");

Inside the Loop() Function

Inside the loop() function, there’s only one line: server.handleClient(). This line continuously checks if any client (such as a browser) is trying to send a request to the ESP32. If so, the ESP32 responds by triggering the appropriate function we defined earlier.

void loop() {
  server.handleClient();
}

Handling Web Requests

Now let’s look at the handle_OnConnect() function. This function handles two types of cases: when the browser is initially loading the web page, and when it’s sending updated servo positions via JavaScript.

We first check if the request has a parameter named “value” using server.hasArg("value"). This happens when the JavaScript code in the browser sends the servo angle value via a GET request. If this parameter exists, we convert the value to an integer and store it in servoPos. Then we send a simple plain-text response saying “OK” so the browser knows the request was received.

If the request does not include a “value” parameter, then it means the user is just visiting the page normally. In that case, we set servoPos to 90 degrees, which acts as a neutral starting position. We then send the full HTML web page to the browser using server.send(), and the content comes from the createHTML() function.

In both cases—whether the request came from the AJAX slider update or from initially loading the page—we move the servo to the new position using myservo.write(servoPos). Finally, we print the current position to the Serial Monitor so we can track changes in real-time.

void handle_OnConnect() {
  int servoPos;

  // Check if value parameter exists
  if (server.hasArg("value")) {
    String valueString = server.arg("value");
    servoPos = valueString.toInt();

    // Send a simple response for AJAX requests
    server.send(200, "text/plain", "OK");
  } else {
    // Move servo to 90 deg initially
    servoPos = 90;

    // Send the main webpage
    server.send(200, "text/html", createHTML());
  }

  // Move servo into position
  myservo.write(servoPos);

  // Print value to serial monitor
  Serial.print("Servo position: ");
  Serial.println(servoPos);
}

If a user tries to access a page that doesn’t exist on the server, the ESP32 runs the handle_NotFound() function, which simply sends a 404 error message—just like any normal website would when you enter a wrong URL.

void handle_NotFound() {
  server.send(404, "text/plain", "Not found");
}

Displaying the HTML Web Page

Now, let’s talk about the createHTML() function. This custom function returns a complete HTML document as a string. This is the web page that you see in your browser when you visit the ESP32’s IP address.

At the beginning of the HTML, we include <!DOCTYPE html> to tell the browser that this is an HTML5 document. We also include a <meta> tag that makes sure the page looks good on all screen sizes, like phones and tablets.

<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">

Styling the Web Page

The <style> section contains all the CSS that gives our page its clean layout. We set the font to sans-serif, center everything on the screen, and design the slider with a nice gradient fill and smooth thumb animations. The slider ranges from 0 to 180 degrees, and the current value is displayed next to it in a large font.

<style>
  html {font-family: sans-serif; text-align: center;}
  h1 {margin: 80px auto 40px; font-weight: 300; font-size: 2.5em;}
  .slider-container {padding-top: 30px; margin: 40px auto; max-width: 500px;}
  .field {display: flex; justify-content: space-between; align-items: center; gap: 20px; margin: 20px 0;}
  .field .value {font-size: 18px; width: 50px;}
  .slider {appearance: none; width: 100%; height: 8px; border-radius: 25px; background: rgba(0, 0, 0, 0.1); outline: none; position: relative; cursor: pointer;}
  .slider-input {position:relative; width: 100%; height: 18px;}
  .slider::-webkit-slider-thumb {appearance: none; width: 28px; height: 28px; border-radius: 50%; background: #fff; cursor: pointer; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 0 3px rgba(255, 255, 255, 0.2); transition: all 0.2s ease; position: relative;}
  .slider::-webkit-slider-thumb:hover {transform: scale(1.1);}
  .position-display {padding: 20px;}
  .position-value {font-size: 2.5em;}
  .slider-fill {position: absolute; height: 8px; background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); border-radius: 25px; pointer-events: none; top: 7px; margin: 0 2px;}
</style>

Setting the Web Page Heading

Next, we add the main heading for the web page.

<h1>Servo Control</h1>

Displaying the Slider

The body of the HTML includes a container that holds the slider input. The slider is flanked by labels showing 0° and 180°, and as the user slides it, a visual fill bar beneath the slider updates to show the current position. Below that, the current angle is displayed dynamically using JavaScript.

<div class="slider-container">
  <div class="field">
    <div class="value">0°</div>
    <div class="slider-input">
      <div class="slider-fill" id="sliderFill"></div>
      <input type="range" min="0" max="180" class="slider" id="servoSlider" onchange="servo(this.value)" value="90"/>
    </div>
    <div class="value">180°</div>
  </div>
  
  <div class="position-display">
    <div>Current Position</div>
    <div class="position-value"><span id="servoPos">90</span>°</div>
  </div>
</div>

Now let’s talk about the JavaScript part. At the bottom of the HTML, we include a <script> block. This script is responsible for updating the servo’s position and reflecting those changes in the UI.

First, we use document.getElementById() to reference the slider, the label showing the angle, and the visual fill element. We then initialize the UI so that the displayed angle and slider fill are in sync with the slider’s starting value.

When the slider is moved, an oninput event is triggered. This updates the displayed angle immediately and also refreshes the fill bar to match the new position. This visual feedback helps the user know exactly what angle they’re selecting.

The key part is the servo() function, which is called whenever the slider is changed. This function creates an XMLHttpRequest object, which sends an asynchronous GET request to the ESP32 server with the new angle as a URL parameter—for example, /value=135. This is AJAX in action: it updates the servo’s position without reloading the entire web page.

The AJAX approach allows the web interface to remain smooth and interactive. Only the necessary data is exchanged between the browser and ESP32, while the rest of the page remains unchanged. The user can keep dragging the slider back and forth, and the servo updates in real-time without any full-page refresh.

<script>
  // Get DOM elements
  var slider = document.getElementById("servoSlider");
  var servoP = document.getElementById("servoPos");
  var sliderFill = document.getElementById("sliderFill");
  
  // Initialize display
  servoP.innerHTML = slider.value;
  updateSliderFill();
  
  // Update display when slider moves
  slider.oninput = function() {
    servoP.innerHTML = this.value;
    updateSliderFill();
  }
  
  // Function to update the visual fill of the slider
  function updateSliderFill() {
    var percentage = (slider.value / 180) * 100;
    sliderFill.style.width = percentage + '%';
  }
  
  // Function to send servo position to ESP32
  function servo(pos) {
    // Create XMLHttpRequest object
    var xhr = new XMLHttpRequest();
    
    // Set timeout
    xhr.timeout = 1000;
    
    // Handle timeout
    xhr.ontimeout = function() {
      console.log('Request timed out');
    };
    
    // Handle errors
    xhr.onerror = function() {
      console.log('Request failed');
    };
    
    // Send GET request to ESP32
    xhr.open('GET', '/?value=' + pos, true);
    xhr.send();
  }
</script>

Finally, the function closes all HTML tags properly and returns the full HTML string.

  </body>
  </html>
)rawliteral";

  return html;
}
Shreepad Prabhu

Shreepad Prabhu

Shreepad is a passionate Electronics & Telecommunication Engineer with a deep love for embedded systems. He has over 15 years of experience, including his time as a Senior Embedded Engineer at Micromax contributing to solutions for Thermo Fisher Scientific, Tata Motors, Liebherr, and John Deere. Since co-founding Last Minute Engineers in 2018, he has written hundreds of articles and guides for Last Minute Engineers to help makers build with confidence. You can find him on LinkedIn