EJS Client-Side Validation

Registered members can download the FREE Get Started App. This is the project I used to compose articles about setting up VS Code and developing Node with Express and the Embedded JavaScript (EJS) view engine.

In ASP.NET Core MVC and Razor Pages, client-side validation is primarily implemented using the jQuery Validation and jQuery Unobtrusive Validation libraries, which leverage data attributes generated from Data Annotations in your model. This provides immediate user feedback and reduces server load, but it does not replace server-side validation for security purposes.

This series will focus on posting form data from the client to the server. Validation should notify the user of invalid input as soon as possible. Password requirements should be listed and indicate if the requirement has been satisfied.

I will use the Get Started App's contact us form for this series. I added a checkbox and a radio group for demonstration. The form is in a Bootstrap modal using Bootstrap classes.

Contact Us Modal with Validation

I added a div for a validation summary, html validation attributes, and native JavaScript functions to validate the data before the form is posted to the server. I use the maxlength and required attributes on the input elements. I use a simple "Human Verification" input to confuse automated submissions by AI and bots. Notice the form attribute on the "Send Message" button matches the form id attribute.

modal.ejs (html)
<div id="contactModal" class="modal fade" tabindex="-1" aria-labelledby="contactModalLabel" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="contactModalLabel">Contact Us</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <div id="validation-summary" class="text-danger">
        </div>
        <form id="contactForm" action="/contact-us" method="POST">            
            <div class="mb-3">                
                <label for="email">Your Email:</label>
                <input class="form-control" type="email" id="email" name="email" maxlength="255" required>
            </div>            
            <div class="mb-3">
                <label for="subject">Subject:</label>
                <input class="form-control" type="text" id="subject" name="subject" maxlength="100" required>
            </div>            
            <div class="mb-3">
                <label for="message">Message:</label>
                <textarea class="form-control mb-1" id="message" name="message" rows="4" cols="50" maxlength="500" required></textarea>
                <span id="character-count">0 of 500</span>
            </div>
            <div class="mb-3 alert alert-primary p-2">
                <div class="form-check">
                    <input class="form-check-input" type="checkbox" id="urgent" name="urgent" value="true">
                    <label class="form-check-label" for="urgent">Urgent Issue</label>
                </div>
            </div>            
            <h6 class="">Scale:</h6>
            <div class="d-inline-flex alert alert-primary p-2">
                <div class="form-check form-check-inline mt-1">
                    <input type="radio" value="1" class="form-check-input" id="Scale1" name="scale">
                    <label class="form-check-label" for="Scale1">1</label>
                </div>
                <div class="form-check form-check-inline mt-1">
                    <input type="radio" value="2" class="form-check-input" id="Scale2" name="scale">
                    <label class="form-check-label" for="Scale2">2</label>
                </div>
                <div class="form-check form-check-inline mt-1">
                    <input type="radio" value="3" class="form-check-input" id="Scale3" name="scale">
                    <label class="form-check-label" for="Scale3">3</label>
                </div>
                <div class="form-check form-check-inline mt-1">
                    <input type="radio" value="4" class="form-check-input" id="Scale4" name="scale">
                    <label class="form-check-label" for="Scale4">4</label>
                </div>
                <div class="form-check form-check-inline mt-1">
                    <input type="radio" value="5" class="form-check-input" id="Scale5" name="scale">
                    <label class="form-check-label" for="Scale5">5</label>
                </div>
            </div>
            <div class="mb-3">
                <label for="verifyhuman">Human Verification:</label><br>
                <input class="form-control d-inline" type="number" style="width: 70px;" id="verifyhuman" name="verifyhuman" required>
                <label class="ms-2">+ 5 = 7</label>
            </div>
        </form>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
        <button type="button" form="contactForm" class="btn-send-message btn btn-primary">Send Message</button>
      </div>
    </div>
  </div>
</div>

The Get Started App implements a site.js client-side JavaScript file in the public folder loaded with the main.ejs layout for every page. The site.js implements get and set cookie functions and a couple of Regular Expression functions to validate the email format and detect HTML tags. Notice the 'use strict' directive which enables strict mode and enforces stricter parsing and error handling. Server-side ES6 modules enable strict mode by default.

site.js
'use strict'

const setCookie = function (cname, cvalue, exdays, path = '/', samesite = 'strict') {
    let d = new Date();
    d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
    document.cookie = cname + '=' + cvalue + ';expires=' + d.toUTCString() +
        ';path=' + path + ';  samesite=' + samesite + ';';
}

const getCookie = (cname) => {
    let name = cname + '=';
    let ca = document.cookie.split(';');
    for (let i = 0; i < ca.length; i++) {
        let c = ca[i];
        while (c.charAt(0) === ' ') c = c.substring(1);
        if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
    }
    return '';
}

const isValidEmail = (email) => {
  const emailRegExp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
  return emailRegExp.test(email);
}

const containsHTML = (text) => {
  const htmlTagRegExp = /<[^>]+>/;
  const htmlEntityRegExp = /&[a-z0-9#]+;/i;
  return htmlTagRegExp.test(text) || htmlEntityRegExp.test(text);
}

document.addEventListener('DOMContentLoaded', () => {
    document.querySelectorAll('.modal').forEach(modal => {
      modal.addEventListener('hide.bs.modal', () => {
        document.activeElement.blur();
      });
    });
});

The event listener on 'hide.bs.modal' remedies the DevTools warning for the aria-hidden attribute on an active or focused element. This warning occurs when closing the modal. The added function to the event removes focus from the close button.

Blocked aria-hidden on an element because its descendant retained focus. The focus must not be hidden from assistive technology users. Avoid using aria-hidden on a focused element or its ancestor. etc.

The modal partial template loads local JavaScript to display a character count for the Message textarea and validate the input values before submitting the form. Notice the "Send Message" button's type attribute is set to button not submit. The button's click event invokes the validateSendMessage function which evaluates the data. If the data does not meet requirements, a validation message is added to the validation summary div and the form is not submitted.

modal.ejs (script)
// Modal html from above
<script>
    'use strict'
    
    const messageInput = document.querySelector('#message');
    const characterCounter = document.querySelector("#character-count");

    const validateSendMessage = () => {

        const email = document.querySelector('#email').value;
        const subject = document.querySelector('#subject').value;
        const message = document.querySelector('#message').value;
        
        const scaleRadio = document.querySelector('input[name="scale"]:checked');
        const scaleValue = scaleRadio ? scaleRadio.value : null; // Returns null if nothing is selected

        const verifyhuman = document.querySelector('#verifyhuman').value;

        const summary = document.querySelector('#validation-summary');
        if (!summary) return false;
        summary.innerHTML = '';
        const ul = document.createElement('ul');
        
        if (!isValidEmail(email)) {
            const li = document.createElement('li');
            li.textContent = 'Invalid email address format.';
            ul.appendChild(li);
        }        
        if (subject.length < 8 || subject.length > 100) {
            const li = document.createElement('li');
            li.textContent = 'Subject must be 8 to 100 characters long.';
            ul.appendChild(li);
        }
        if (containsHTML(subject)) {
            const li = document.createElement('li');
            li.textContent = 'Subject contains HTML characters.';
            ul.appendChild(li);
        }
        const messageLength = messageInput.value.replace(/\n/g, "\r\n").length;
        if (messageLength < 20 || messageLength > 500) {
            const li = document.createElement('li');
            li.textContent = 'Message must be 20 to 500 characters long.';
            ul.appendChild(li);
        }
        if (containsHTML(message)) {
            const li = document.createElement('li');
            li.textContent = 'Message contains HTML characters.';
            ul.appendChild(li);
        }
        
        if (scaleValue === null) {
            const li = document.createElement('li');
            li.textContent = 'Scale selection is required.';
            ul.appendChild(li);
        }
        
        if (parseInt(verifyhuman) != 2) {
            const li = document.createElement('li');
            li.textContent = 'Human verification failed.';
            ul.appendChild(li);
        }

        if (ul.hasChildNodes()) {
            summary.appendChild(ul);
            return false;
        }

        return true;
    }

    const countCharacters = () => {
        let counter = messageInput.value.replace(/\n/g, "\r\n").length;
        characterCounter.textContent = counter + " of 500";
    };

    document.addEventListener('DOMContentLoaded', () => {

        messageInput.addEventListener("input", countCharacters);

        document.querySelector('.btn-send-message').addEventListener('click', (e) => {
            if (validateSendMessage()) e.target.form.submit();
        });
    });
</script>
Created: 2/16/26