Bootstrap 5 Form Validation: Native Rules, Custom Messages and Server Errors

Bootstrap 5 Form Validation: Native Rules, Custom Messages and Server Errors

was-validated CSS hook, covers the majority of real-world requirements without a single third-party library. But the defaults have sharp edges: browser-generated error messages are inconsistent across operating systems, server-side errors need manual wiring, and the standard submit-block pattern can break progressive enhancement. This guide walks through every layer, from native HTML rules to custom JavaScript messages to feeding back errors from your server.

Key Takeaways

  • Bootstrap 5 form validation is built on the native browser Constraint Validation API, not a separate library.
  • Adding the was-validated class to a <form> element triggers Bootstrap’s valid and invalid visual states.
  • Custom validation messages require calling setCustomValidity() or populating .invalid-feedback elements directly.
  • Server-side errors must be injected into the DOM after the response arrives, because Bootstrap has no built-in AJAX error layer.
  • The novalidate attribute is required on the form element to suppress native browser popups while still accessing the Constraint Validation API in JavaScript.
  • Accessibility requires pairing every .invalid-feedback element with aria-describedby on its input.

How Bootstrap 5 Form Validation Works Under the Hood

Bootstrap 5 does not ship its own validation engine. It piggybacks on the Constraint Validation API built into every modern browser. When you call form.checkValidity(), the browser evaluates every field against its HTML attributes (required, minlength, pattern, type="email", and so on) and sets each element’s validity object accordingly.

Bootstrap’s role is purely visual. It defines :valid and :invalid pseudo-class styles, but wraps them behind a .was-validated parent selector so they only appear after the user has attempted to submit. Without that guard, a required-but-empty field would show red on page load.

The standard Bootstrap starter block looks like this:

<form id="contact-form" novalidate>
  <div class="mb-3">
    <label for="email" class="form-label">Email address</label>
    <input
      type="email"
      class="form-control"
      id="email"
      name="email"
      required
      aria-describedby="email-feedback"
    >
    <div class="invalid-feedback" id="email-feedback">
      Please enter a valid email address.
    </div>
  </div>
  <button type="submit" class="btn btn-primary">Send</button>
</form>

<script>
  const form = document.getElementById('contact-form');
  form.addEventListener('submit', function (event) {
    if (!form.checkValidity()) {
      event.preventDefault();
      event.stopPropagation();
    }
    form.classList.add('was-validated');
  });
</script>

Two points matter here. First, novalidate suppresses the browser’s native tooltip popups while keeping the Constraint Validation API active, so checkValidity() still works. Second, form.classList.add('was-validated') is what actually reveals the coloured borders and feedback text.

Bootstrap 5 Form Validation: Native Rules, Custom Messages and Server Errors, abstract concept illustration

Native HTML Validation Rules Worth Knowing

Before reaching for JavaScript, exhaust the HTML attributes. They cover more ground than most developers expect:

  • required: field must not be empty.
  • type="email": basic format check (local-part + @ + domain).
  • type="url": must start with a valid scheme.
  • minlength / maxlength: character count constraints.
  • min / max: numeric or date range constraints.
  • pattern: a regular expression the value must match in full.
  • step: numeric step validity (useful for quantity fields).

A phone number field that must contain 10 to 15 digits only:

<input
  type="tel"
  class="form-control"
  id="phone"
  name="phone"
  pattern="[0-9]{10,15}"
  required
  aria-describedby="phone-feedback"
>
<div class="invalid-feedback" id="phone-feedback">
  Enter a phone number between 10 and 15 digits.
</div>

Writing Custom Validation Messages

The browser’s built-in messages (“Please fill out this field”, “Please enter an email address”) are translated but inconsistent across Chrome, Firefox, and Safari. They also cannot be styled. There are two ways to replace them.

Option 1: Static text in .invalid-feedback. Bootstrap hides .invalid-feedback by default and only shows it when the parent has .was-validated and the field is :invalid. Write your message directly in the HTML. The browser popup is suppressed by novalidate, so your static text is all the user ever sees. This works well when one message covers all failure modes for a field.

Option 2: setCustomValidity() for per-rule messages. When a field can fail for multiple reasons (empty vs. wrong format), use the Constraint Validation API to set a contextual message:

<script>
  const emailInput = document.getElementById('email');

  emailInput.addEventListener('input', function () {
    if (emailInput.validity.valueMissing) {
      emailInput.setCustomValidity('An email address is required.');
    } else if (emailInput.validity.typeMismatch) {
      emailInput.setCustomValidity('That does not look like an email address.');
    } else {
      emailInput.setCustomValidity('');
    }
    // Reflect the message in the visible feedback element
    const feedback = document.getElementById('email-feedback');
    feedback.textContent = emailInput.validationMessage;
  });
</script>

Calling setCustomValidity('') (empty string) clears the custom state so the field is treated as valid. Always call it on input events, not just on submit, so the message clears as soon as the user corrects the value.

custom validation messages, abstract technical diagram

Injecting Server-Side Errors

Bootstrap’s validation layer is entirely client-side. If your server returns a 422 response saying the email address is already registered, you must wire that back into the DOM yourself. The cleanest approach reuses the same .invalid-feedback element and adds Bootstrap’s is-invalid class directly to the input:

<script>
  async function submitForm(event) {
    event.preventDefault();
    const form = event.target;

    // Clear any previous server errors
    form.querySelectorAll('.is-invalid').forEach(el => {
      el.classList.remove('is-invalid');
    });

    const response = await fetch('/api/contact', {
      method: 'POST',
      body: new FormData(form)
    });

    if (response.ok) {
      // Handle success — e.g. show a Bootstrap Toast
      return;
    }

    const errors = await response.json(); // { email: "Already registered." }

    Object.entries(errors).forEach(([field, message]) => {
      const input = form.elements[field];
      if (!input) return;
      input.classList.add('is-invalid');
      const feedback = document.getElementById(field + '-feedback');
      if (feedback) feedback.textContent = message;
    });
  }

  document.getElementById('contact-form').addEventListener('submit', submitForm);
</script>

is-invalid does not require .was-validated on the form. It is a standalone utility class that forces the red state immediately, which keeps server errors visually consistent with client errors without re-running checkValidity(). For surfacing success and failure outcomes in a non-blocking way, pairing this with Bootstrap 5 Toasts creates a clean user experience.

Accessibility Considerations

Colour alone is not sufficient for users relying on assistive technology. Every .invalid-feedback element should be linked to its input via aria-describedby (shown in the examples above). When the feedback text changes dynamically, add role="alert" or aria-live="polite" to the feedback element so screen readers announce it without requiring focus:

<div class="invalid-feedback" id="email-feedback" aria-live="polite">
  Please enter a valid email address.
</div>

Also confirm that the green/red colour contrast meets WCAG 2.1 AA. Bootstrap’s default $form-valid-color and $form-invalid-color pass on white backgrounds but may fail on tinted card backgrounds. If you are working from a premium template like the Canvas HTML Template with custom brand colours, override those Sass variables explicitly rather than relying on the defaults.

Multi-Step Forms and Live Validation Patterns

Multi-step forms need per-step validation before advancing. The trick is to validate only the fields in the current step rather than the whole form:

<script>
  function validateStep(stepEl) {
    let valid = true;
    stepEl.querySelectorAll('input, select, textarea').forEach(input => {
      if (!input.checkValidity()) {
        input.classList.add('is-invalid');
        valid = false;
      } else {
        input.classList.remove('is-invalid');
        input.classList.add('is-valid');
      }
    });
    return valid;
  }

  document.getElementById('next-btn').addEventListener('click', function () {
    const currentStep = document.querySelector('.step.active');
    if (validateStep(currentStep)) {
      // advance to next step
    }
  });
</script>

For live (inline) validation, attach listeners on blur rather than input. Validating on every keystroke is noisy and interrupts typing. Validate on blur when the user leaves the field, and clear errors on input as soon as they start correcting. This pattern is sometimes called “blur to validate, input to forgive”.

If you want to keep custom JavaScript clean and maintainable as templates grow, the post on replacing jQuery plugins with vanilla JavaScript covers the same modular approach applied to other UI components.

Common Pitfalls and How to Avoid Them

  • Forgetting novalidate. Without it, the browser shows its own popup before your JavaScript runs, producing duplicate or conflicting messages.
  • Adding was-validated too early. If you add the class on page load or on the first input event, pristine fields immediately show as invalid. Add it only after a submit attempt.
  • Not clearing setCustomValidity. Set a custom message on submit but never clear it, and the field stays invalid even after the user fixes the value. Always reset to an empty string on valid input.
  • Mixing is-invalid and was-validated without a plan. Use was-validated for bulk client-side results after submit. Use is-invalid / is-valid for targeted, field-by-field control such as server errors or live validation. Applying both strategies to the same field simultaneously causes state conflicts.
  • Invisible feedback on custom backgrounds. The .invalid-feedback element uses display: none until its parent contains either .was-validated :invalid or a sibling with .is-invalid. If your markup nests the feedback inside an extra wrapper div, the CSS selector will not reach it.

Frequently Asked Questions

Adding was-validated to a <form> element activates Bootstrap’s CSS rules that show green borders and tick icons on :valid fields, and red borders with .invalid-feedback text on :invalid fields. Without that class, those styles are intentionally hidden so the form does not show errors before the user has interacted with it.

Partially. You can omit novalidate and let the browser handle validation natively, which blocks submission and shows browser-generated popups. However, you cannot use was-validated without JavaScript because something must add that class after a submit attempt. For any meaningful UX control, at least the small submit-listener snippet shown in this article is required.

Use setCustomValidity() inside an input or change event listener. Check the field’s validity object properties (valueMissing, typeMismatch, patternMismatch, etc.) and call setCustomValidity() with the appropriate string. Then write that string into the .invalid-feedback element’s textContent so sighted users see it. Reset to an empty string when the field is valid.

Bootstrap’s visual states work on file inputs the same as any other input, including the required constraint. However, file type and size validation cannot be done with HTML attributes alone. You need to inspect input.files[0].type and input.files[0].size in JavaScript, then call setCustomValidity() with an appropriate message and add is-invalid manually if the file fails your rules.

Modal forms need one extra step: reset validation state when the modal closes, otherwise old errors show the next time it opens. Listen for Bootstrap’s hidden.bs.modal event, then remove was-validated from the form, remove all is-invalid and is-valid classes from inputs, and reset setCustomValidity('') on any fields that had custom messages. A single form.reset() clears values but does not clear validation classes.

Looking for a production-ready Bootstrap 5 HTML template? Browse Canvas Template demos and find the perfect starting point for your next project.

If you’re building with the Canvas HTML Template and want to ship production-ready Bootstrap 5 layouts faster, try Canvas Builder free — the visual builder that exports clean Canvas-ready markup in minutes.

Skip the setup and build it free

Spin up a complete Bootstrap 5 site, blog included, with Canvas Builder. No coding, no cost.

Share:
Rohit S
Rohit S

Rohit S builds Canvas at SemiColonWeb, the team behind the Canvas HTML Template, CanvasWP and CanvasBuilder AI.

More from the Canvas Blog