Here is an SEO Friendly Way to Handle Forms with Web Components

Featured image for “Here is an SEO Friendly Way to Handle Forms with Web Components”

A guide on how to handle forms with web components in an SEO-friendly method.

A general misconception around Web Components exists in the community which, in my opinion, is because we are so used to these JavaScript-heavy web libraries and framework echo-system that it becomes easier to think of Web Components technology as something that is “lacking”. Which, to an extent, is true.

I see web components technology as an opportunity to rethink the way we build the web. It requires you to cleanse from the madness of React, Vue.js, and the Angular world to step into that creative mindset to see its potential. It has a lot of room to improve yes, but it is up to us to define what it can become.

Let’s look at some of the criticism:

  • Still requires JavaScript: I find this criticism hypocritical when almost all UI libraries and frameworks require JavaScript or some other language to work. If React projects can be built on the server with SSR, so can web components.
  • Not SEO friendly: Most web libraries compile plain HTML and JavaScript to work on the browser. By exposing the inside of the components in the browser allows you to be SEO friendly, especially if you SSR. Again, I would like to challenge that in this article by showing a different way to approach this problem.
  • API is not good enough: using web components technology does not save you from the painful and tedious work of doing DOM manipulation and manual state management. I feel the community on that. Again, that's why we have web components libraries as well as other web libraries to improve the developer experience. They are all attempts at improving the DOM API experience.

Allow me to show you how

Let’s start with a form since it is commonly used to demonstrate how Web Components are not SEO friendly. Look at the following:

<form action="/login" method="post" novalidate>
    <p class="error-message" hidden></p>
    <input type="email" name="email"
       placeholder="Email"
       title="Please provide email" required>
    <input type="password" name="password"
       placeholder="Password"
       title="Please provide password" required>
    <button>Login</button>
</form>

When you use popular web libraries, you can put this code in your app, then build (or compile) your project and it will be added to the DOM when the JavaScript loads (if enabled).

One SEO issue with this is that you need JavaScript to run to produce this form output as well as placing this code on the DOM. The way the community decided to address this issue is to render it on the server. Then, on the client when the JavaScript loads, it hooks to and controls the form. Pretty simple.

Let’s say we decided to create web components for this form. We created some components and ended up on something like this:

<login-form>
  <email-field></email-field>
  <password-field></password-field>
  <submit-button></submit-button>
</login-form>

Here is a very simple example but there are major limitations here, including SEO issues.

Until JavaScript runs, this makes no sense to the browser or search engines that come to our websites. We are abstracting everything inside the components which requires JavaScript to run and make sense. If rendering this on the server, it would still not make sense when it gets to the browser.

We normally think of components as these tags we can use. This mindset is built from using popular web libraries like React and Angular. There is another way to use components that require no content abstraction by leveraging the Native Web Components API.

We could create a component which body is a single slot tag to do something like this:

<login-form>
  <form action="/login" method="post" novalidate>
    <p class="error-message" hidden></p>
    <input type="email" name="email"
       placeholder="Email"
       title="Please provide email" required>
    <input type="password" name="password"
       placeholder="Password"
       title="Please provide password" required>
    <button>Login</button>
  </form>
</login-form>

This means that if the JavaScript is disabled or this is rendered on the server, the search engines would still see the form, and when JavaScript kicks in, it will attach the logic of login-form to the DOM elements and work just fine.

But, this is still not good because now inside login-form component class, you have to do some tedious work of DOM manipulation which web components API does not help much with. That’s where you lose hope for Web Components. It's too much code for things we are used to from popular web frameworks.

Enters CWCO

CWCO is a Contextful Web Components library (pronounced Cuoco) that addresses several pain points of dealing with Web Components API. It includes features like event and data binding in HTML or CSS, directives, built-in context concept, improved lifecycles, reactive properties, etc.

Unlike other Web Component libraries, it is a plug-and-play library that does not require your project to be compiled or built relying solely on web standards to do almost everything. It feels super vanilla when you work with it. It’s intuitive and easy to learn if you already know JavaScript.

It is also super light and powerful and you should think of it like jQuery on steroids for Web Components. It also runs on Node servers which provides a potential chance for SSR web components.

Let’s look at how we can build a generic form controller for any forms we want to control and still be SEO-friendly. We can continue from this example:

<form-controller>
  <form action="/login" method="post" novalidate>
    <p class="error-message" hidden></p>
    <input type="email" name="email"
       placeholder="Email"
       title="Please provide email" required>
    <input type="password" name="password"
       placeholder="Password"
       title="Please provide password" required>
    <button>Login</button>
  </form>
</form-controller>

Here is how we initialize the form-controller component with CW.

class FormController extends ContextProviderComponent {
   // code here
}
FormController.register(); // defines the tag

You simply create a class that extends the standard [WebComponent](https://github.com/beforesemicolon/cwco/blob/master/docs/WebComponent.md) or [ContextProviderComponent](https://github.com/beforesemicolon/cwco/blob/master/docs/ContextProviderComponent.md) . For this example, I want a context provider component that does a little more extra things than the standard one. It allows for the context provider component and its body can be defined directly on the HTML file.

The data

We can define the observable attributes for the component which is still done like a native Web Component API.

For this one, we want to know how we want to submit the form data with the body-type attribute, and when to validate the fields with validate-on attribute. I then proceed to define their default values below

class FormController extends ContextProviderComponent {
  static observedAttributes = ['body-type', 'validate-on'];
  bodyType = 'form-data'; // json | form-data
  validateOn = 'change'; // both | change | input
}
FormController.register();

Note that every public property in the class will trigger a DOM update when they change via re-assignment or deep object update. Also, every observed attribute gets an equivalent camel-cased property you can access it from inside or outside the class through element instance object.

This simple setup is already doing a lot for us.

Now we can declare the properties related to the form. We want to hold reference of the form and the data object containing its values and the error object that will tell us where there is something wrong.

class FormController extends ContextProviderComponent {
  static observedAttributes = ['body-type', 'validate-on'];
  bodyType = 'form-data'; // json | form-data
  validateOn = 'change'; // both | change | input

  data = {};
  form = null;
  error = {
    message: '',
    field: ''
  };
  submit(event) {
     event.preventDefault()
  }
}
FormController.register();

We can also declare the function that will handle the form submission as normal.

We could use CW data-binding syntax directly on the template to set up our form like so:

<form-controller>
  <form action="/login" method="post" novalidate
    onsubmit="submit($event)">
    <p class="error-message" hidden
      attr.hidden="!error.message">{error.message}</p>
    <input type="email" name="email"
       placeholder="Email" value="{data.email || ''}"
       oninput="{this.data['email'] = $event.target.value}"
       title="Please provide email" required>
    <input type="password" name="password"
       placeholder="Password" value="{data.pass || ''}"
       oninput="{this.data['pass'] = $event.target.value}"
       title="Please provide password" required>
    <button>Login</button>
  </form>
</form-controller>

All the inline event listeners are removed from the element when the component renders are replaced by event listeners in the background. Changing the data object would trigger DOM updates and everything would work just fine. It is that simple.

Although this does the trick…remember we want to be SEO and No-JavaScript friendly. One issue with this is that, if JavaScript is disabled, the field would look like this:

Illustration for “Here is an SEO Friendly Way to Handle Forms with Web Components”

This is because HTML does not recognize CWCO curly braces syntax.

Also, if we server-side render this form, we want to put the correct values into this field with the value attribute, so let’s go back to just having plain form.

I set a ref attribute of value ‘form’ which is a way CWCO collects DOM references.

<form-controller>
  <form action="/login" method="post" novalidate ref="form">
    <p class="error-message" hidden></p>
    <input type="email" name="email"
       placeholder="Email"
       title="Please provide email" required>
    <input type="password" name="password"
       placeholder="Password"
       title="Please provide password" required>
    <button>Login</button>
  </form>
</form-controller>

Now, when the component mounts, we want to grab the reference of the form.

class FormController extends ContextProviderComponent {
  static observedAttributes = ['body-type', 'validate-on'];
  bodyType = 'form-data'; // json | form-data
  validateOn = 'change'; // both | change | input

  data = {};
  form = null;
  error = {
    message: '',
    field: ''
  };
  onMount() {
    this.form = this.$refs.form;

    if(!this.form) {
      const el = this.querySelector('form');

      if(el.nodeName === 'FORM') {
        this.form = el;
      }
    }
  }
  submit(event) {
     event.preventDefault()
  }
}
FormController.register();

We do that with the [ref](https://github.com/beforesemicolon/cwco/blob/master/docs/directives.md#ref) directive set on the form and then reading the $refs property on the class accessing form property which is the value we set for ref attribute.

I also included a fallback to query the body of the component for a form in case the user does not set the form ref attribute on the form.

To make this generic I’ll have to collect all fields in the form like so:

class FormController extends ContextProviderComponent {

  ...
  onMount() {
    this.form = this.$refs.form;

    if(!this.form) {
      const el = this.querySelector('form');

      if(el.nodeName === 'FORM') {
        this.form = el;
      }
    }
    if(this.form) {
      [...this.form].forEach(field => {
        if(field.nodeName !== 'BUTTON') {
          const {name, value} = field;

          if(name) {
            this.data[name] = value;
          }
        }
      })

      this.form.addEventListener('submit', this.submit.bind(this));
    }
  }
  submit(event) {
     event.preventDefault()
  }
}
FormController.register();

I use the field name attribute to set a key in the data object and set its value as well. I also attached a submit event listener to the form to avoid event data binding on the template.

Handle form submission

class FormController extends ContextProviderComponent {
  static observedAttributes = ['body-type', 'validate-on'];
  bodyType = 'form-data'; // json | form-data
  validateOn = 'change'; // both | change | input
  data = {};
  form = null;
  error = {
    message: '',
    field: ''
  };
  get isValid() {
    return this.form
      ? this.form.checkValidity()
      : false;
  }
  onMount() {
    ...
  }
  submit(event) {
    event.preventDefault();
    this.dispatchEvent(new Event('submit'));

    if(this.isValid) {
      const {method, action} = this.form;

      if(action) {
        fetch(action, {
          method: method.toUpperCase(),
          headers: {
            'Content-Type': this.bodyType === 'json'
                ? 'application/json'
                : 'multipart/form-data',
          },
          body: this.bodyType === 'json'
            ? JSON.stringify(this.data)
            : new FormData(this.form)
        });
      }
    }
  }
}
FormController.register();

The first thing I do is prevent default form submission and dispatch a submit event in case you want to listen to submit event on form-controller element.

After, when confirmed that the form is valid by calling checkValidity method, we can proceed to send the form data.

We do a fetch request using the form action to get the endpoint and method to know which type of request we need to make. We also use the form controller own properties( bodyType) to determine which type of body to use and to set the right headers as well.

We could also use the form enctype attribute value to determine which body to use as well but this setup does the trick and it is generic enough.

One issue now is that we need to update the data object when there is a change in the fields. For that, we can attach the input and change event listeners and validate them accordingly as well.

class FormController extends ContextProviderComponent {

  ...
  static validateField(field) {
    if(field.nodeName !== 'BUTTON' && !field.checkValidity()) {
      return {
        message: field.title,
        field: field.name
      }
    }

    return {
      message: '',
      field: ''
    }
  }
  onMount() {
    this.form = this.$refs.form;

    if(!this.form) {
      const el = this.querySelector('form');

      if(el.nodeName === 'FORM') {
        this.form = el;
      }
    }
   if(this.form) {
      [...this.form].forEach(field => {
        if(field.nodeName !== 'BUTTON') {
          const {name, value} = field;

          if(name) {
            this.data[name] = value;
            field.addEventListener('input', () => {
              this.data[name] = field.value;

              if(/^(both|input)$/.test(this.validateOn)) {
                this.error = FormController.validateField(field);
              }
            })

            field.addEventListener('change', () => {
              this.data[name] = field.value;

              if(/^(both|change)$/.test(this.validateOn)) {
                this.error = FormController.validateField(field);
              }
            })

           }
        }
      })

      this.form.addEventListener('submit', this.submit.bind(this));
    }
  }
  submit(event) {
     ...
  }
}
FormController.register();

With this, we have a generic form-controller web component which template is directly set on the body to make it SEO friendly avoiding encapsulating all the inner element search engines would need to access. This also allows it to be server-sided rendered to work normally.

It is also no-JavaScript friendly. If the JavaScript is disabled form-controller tag would never execute and carry meaning allowing the form to work normally without JavaScript.

Error handling

We can do all our error handling now on the HTML by setting pattern and other attributes on the fields for validation, then set title containing the error messages we want to display when something goes wrong.

<form-controller body-type="json">
  <form action="/login" method="post" novalidate id="login-form">
    <p attr.hidden="!error.message" hidden>
      {error.message}
    </p>
    <input type="email" name="email"
           placeholder="Email"
           title="Email field cannot be empty"
           required
           attr.class.error-field="error.field === 'email'">
    <input type="password" name="pass"
           placeholder="Password"
           title="Password field cannot be empty"
           required
           attr.class.error-field="error.field === 'pass'">
    <button>Login</button>
  </form>
</form-controller>

Here I used the attr directive to conditionally set the error-field class on the fields if the error field ends up matching their name attribute. The title attribute contains the error message and for this example, they are both required.

For the error display, I used the attr directive again to toggle the error paragraph on or off if there is an error.

Styling

By default, ContextProviderComponents have no shadow root. You can set it using the statics [mode](https://github.com/beforesemicolon/cwco/blob/master/docs/configurations.md#mode) property on the class.

You can learn about styling with CW by checking this doc. In general, it is pretty straightforward to use an external stylesheet, link to an external stylesheet inside CWCO components, as well as define your style.

The biggest advantage of this

One thing you can only do with Web Components is to build a component and use it anywhere along with any other web library or framework. CWCO is the same thing since it requires no build. You can literally use this component with your React, Angular, Vue.js, among other projects.

Try this code by checking this pen on Codepen

Conclusion

Web Components technology brings a lot to the table. CWCO is an example of a project that really shows its power and potential by addressing its issues differently. It will only keep on improving.

This generic form controller component can be further improved for specific form elements edge cases and include submitting states as well. You can get an idea of that by checking the source code of this article.

Read the article below to learn more about CW or its docs for more details. Let me know what you think in the comments.

Introducing CWCO — The Only Web Components Solution You Need *A context-full web component library meant to simplify the way you work with native web component APIs in the browser…*javascript.plainenglish.io

Illustration for “Here is an SEO Friendly Way to Handle Forms with Web Components”

YouTube Channel: Before SemicolonWebsite: beforesemicolon.com

Share this article