Form Validation

Bootstrap · Lesson 17 of 28 6 min read Updated

A form that accepts a 9-digit mobile number or an empty email will cause trouble later: SMS that never arrives, customers you cannot contact. Validation checks the input before it is sent and tells the user clearly what to fix. Bootstrap does not invent its own validation system. It styles the browser's built-in HTML validation and gives you classes for messages that come from your server.

In this lesson you will validate a form in the browser with HTML attributes and a few lines of JavaScript, add a custom rule, and show errors that come back from the server.

Step 1: rules in HTML

The browser already understands rules written as HTML attributes (see HTML Form Validation):

AttributeRule
requiredMust not be empty
type="email", type="url"Must look like an email or a web address
minlength, maxlengthText length limits
min, max, stepNumber limits
patternMust match a regular expression

Each field gets a :valid or :invalid state that CSS can see and JavaScript can check with checkValidity().

Step 2: Bootstrap's was-validated pattern

The browser's own error bubbles look different in every browser and cannot be styled. Bootstrap's approach:

  1. Add novalidate to the <form>, which turns off the browser bubbles (the rules still work).
  2. Put a div with invalid-feedback (and optionally valid-feedback) right after each field. They are hidden until validation runs.
  3. On submit, check the form with JavaScript. If it is invalid, stop the submission. In both cases add the was-validated class to the form, which makes Bootstrap colour each field red or green and show the matching feedback.
HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Sign-up validation</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container py-3" style="max-width: 560px">
    <h1 class="h4 mb-3">Create your account</h1>
    <form id="signup" class="row g-3" novalidate>
      <div class="col-12">
        <label for="fullName" class="form-label">Full name</label>
        <input type="text" class="form-control" id="fullName" required minlength="3" aria-describedby="nameFeedback">
        <div id="nameFeedback" class="invalid-feedback">Please enter at least 3 letters.</div>
      </div>
      <div class="col-md-6">
        <label for="mobile" class="form-label">Mobile number</label>
        <div class="input-group has-validation">
          <span class="input-group-text">+91</span>
          <input type="tel" class="form-control" id="mobile" required pattern="[6-9][0-9]{9}" inputmode="numeric" aria-describedby="mobileFeedback">
          <div id="mobileFeedback" class="invalid-feedback">Enter a 10-digit mobile number starting with 6, 7, 8 or 9.</div>
        </div>
      </div>
      <div class="col-md-6">
        <label for="email" class="form-label">Email</label>
        <input type="email" class="form-control" id="email" required aria-describedby="emailFeedback">
        <div id="emailFeedback" class="invalid-feedback">Enter an email such as priya@example.com.</div>
      </div>
      <div class="col-md-6">
        <label for="password" class="form-label">Password</label>
        <input type="password" class="form-control" id="password" required minlength="8" aria-describedby="passFeedback">
        <div id="passFeedback" class="invalid-feedback">Use at least 8 characters.</div>
      </div>
      <div class="col-md-6">
        <label for="confirm" class="form-label">Confirm password</label>
        <input type="password" class="form-control" id="confirm" required aria-describedby="confirmFeedback">
        <div id="confirmFeedback" class="invalid-feedback">Passwords must match.</div>
      </div>
      <div class="col-12">
        <div class="form-check">
          <input class="form-check-input" type="checkbox" id="terms" required aria-describedby="termsFeedback">
          <label class="form-check-label" for="terms">I agree to the terms</label>
          <div id="termsFeedback" class="invalid-feedback">You must agree before signing up.</div>
        </div>
      </div>
      <div class="col-12">
        <button class="btn btn-primary" type="submit">Sign up</button>
      </div>
    </form>
    <p id="result" class="mt-3"></p>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    const form = document.getElementById("signup");
    const password = document.getElementById("password");
    const confirm = document.getElementById("confirm");

    // A custom rule: setCustomValidity("") means valid, any other text means invalid.
    function checkMatch() {
      confirm.setCustomValidity(confirm.value === password.value ? "" : "Passwords do not match");
    }
    password.addEventListener("input", checkMatch);
    confirm.addEventListener("input", checkMatch);

    form.addEventListener("submit", (event) => {
      checkMatch();
      if (!form.checkValidity()) {
        event.preventDefault();
        form.querySelector(":invalid").focus(); // take the user to the first problem
      } else {
        event.preventDefault(); // demo only: a real form would be sent to the server
        document.getElementById("result").textContent = "All good. The form would be sent now.";
      }
      form.classList.add("was-validated");
    });
  </script>
</body>
</html>

Click "Sign up" with the form empty: every field turns red with its message, and the cursor jumps to the first problem. Type valid values and the fields turn green as you go, because was-validated stays on the form and the CSS reacts to :valid and :invalid live.

Key parts of the script:

  • form.checkValidity() returns true only if every field passes its rules.
  • setCustomValidity() adds rules HTML cannot express, such as "the two passwords must match". An empty string means valid. Bootstrap's red styling picks this up automatically, because it simply follows :invalid.
  • Inside an input group, add has-validation to the group so the rounded corners stay correct when the feedback is shown.
  • aria-describedby links each message to its field, so a screen reader reads "Mobile number, invalid entry, Enter a 10-digit mobile number...".

Server-side validation and is-invalid

Browser checks are only for convenience. Anyone can switch off JavaScript or send a request without your form, so your server (for example, PHP) must check everything again. When the server finds a problem, such as "this email is already registered", it sends the form back with the error marked using is-invalid on the field:

HTML
<div class="mb-3">
  <label for="email2" class="form-label">Email</label>
  <input type="email" class="form-control is-invalid" id="email2" value="meera@example.com" aria-describedby="email2Feedback">
  <div id="email2Feedback" class="invalid-feedback">This email is already registered. Try logging in instead.</div>
</div>

is-invalid and is-valid work without was-validated and without novalidate; they mark exactly the fields you choose. This is also the better choice when you want to mark only the fields with errors and not paint every correct field green.

Tooltip-style feedback

If the layout has no room for messages under the fields, use invalid-tooltip (or valid-tooltip) instead. The message appears as a small floating box, so the parent needs position-relative:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Server errors</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container py-3" style="max-width: 560px">
    <div class="alert alert-danger" role="alert">Please fix 2 problems in the form.</div>
    <form class="row gx-3 gy-5">
      <div class="col-md-6 position-relative">
        <label for="pin" class="form-label">PIN code</label>
        <input type="text" class="form-control is-invalid" id="pin" value="12400" aria-describedby="pinTip">
        <div id="pinTip" class="invalid-tooltip">A PIN code has 6 digits.</div>
      </div>
      <div class="col-md-6 position-relative">
        <label for="city" class="form-label">City</label>
        <input type="text" class="form-control is-valid" id="city" value="Rohtak">
        <div class="valid-tooltip">Looks good.</div>
      </div>
      <div class="col-12 position-relative">
        <label for="date" class="form-label">Delivery date</label>
        <input type="date" class="form-control is-invalid" id="date" value="2026-01-01" aria-describedby="dateTip">
        <div id="dateTip" class="invalid-tooltip">The date must be today or later.</div>
      </div>
    </form>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

The alert at the top summarises the problems, which helps screen-reader users and anyone who scrolled past the errors.

Common mistakes

  • Forgetting novalidate. The browser shows its own bubbles on top of Bootstrap's messages.
  • Feedback div in the wrong place. invalid-feedback must be a sibling that comes after the field (inside the same parent), or Bootstrap's CSS cannot show it.
  • Trusting browser validation alone. It can be bypassed in seconds. Always validate again on the server.
  • Messages that only say "Invalid". Say what is expected: "Enter a 6-digit PIN code".

Key points

  • Write rules with HTML attributes such as required, type, minlength and pattern.
  • Add novalidate, check with checkValidity() on submit, and add was-validated to show Bootstrap's styles and invalid-feedback messages.
  • setCustomValidity() adds custom rules that Bootstrap styles automatically.
  • Use is-invalid and is-valid for errors returned by the server, and invalid-tooltip when space is tight.
  • Link messages with aria-describedby, and always validate again on the server.

Exercise

Build a ticket booking form for a cricket match in Lucknow with: name (required), age (number, 5 to 90), number of tickets (1 to 6) and a seat stand select whose first option "Choose a stand" has an empty value and must not be accepted. Use the was-validated pattern with a clear message under every field.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Match tickets</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container py-3" style="max-width: 520px">
    <form id="tickets" class="row g-3" novalidate>
      <div class="col-12">
        <label for="name" class="form-label">Name</label>
        <input type="text" class="form-control" id="name" required>
        <div class="invalid-feedback">Please enter your name.</div>
      </div>
      <div class="col-6">
        <label for="age" class="form-label">Age</label>
        <input type="number" class="form-control" id="age" min="5" max="90" required>
        <div class="invalid-feedback">Age must be between 5 and 90.</div>
      </div>
      <div class="col-6">
        <label for="qty" class="form-label">Tickets</label>
        <input type="number" class="form-control" id="qty" min="1" max="6" value="1" required>
        <div class="invalid-feedback">You can book 1 to 6 tickets.</div>
      </div>
      <div class="col-12">
        <label for="stand" class="form-label">Stand</label>
        <select class="form-select" id="stand" required>
          <option value="">Choose a stand</option>
          <option>North stand</option>
          <option>Pavilion</option>
        </select>
        <div class="invalid-feedback">Please choose a stand.</div>
      </div>
      <div class="col-12"><button class="btn btn-primary" type="submit">Book</button></div>
    </form>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    const form = document.getElementById("tickets");
    form.addEventListener("submit", (event) => {
      if (!form.checkValidity()) {
        event.preventDefault();
      }
      form.classList.add("was-validated");
    });
  </script>
</body>
</html>

A required select fails when the chosen option has an empty value, so "Choose a stand" is rejected. min and max handle the number ranges without any extra JavaScript.

Quick Quiz

1. What does the novalidate attribute on a form do?

2. When does a div with invalid-feedback become visible?

3. How do you add a rule such as "passwords must match"?

4. Why must the server validate the data again?

Learn Bootstrap with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call