Building a Renderless reCAPTCHA v3 Form Component with Vue.js

The modern frontend stack is all about reusable components. The renderless component pattern is one of the most elegant ways of how to build highly reusable components. Today we’ll build a renderless component for handling form submission, loading and error logic in a generic and reusable way. Additionally we’ll take a look at how we can use the new reCAPTCHA v3 to secure our form from spam submissions.

Generic error and success handling

You can take a look at the full code at GitHub and you can try out the demo hosted on Netlify.

Building the form component

The following code is loosely based on the code I’ve written for one of my previous articles about renderless components for handling CRUD operations.

import { post } from '../../utils/api';

export default {
  props: {
    data: {
      required: true,
      type: Object,
    },
    endpoint: {
      required: true,
      type: String,
    },
  },
  data() {
    return {
      error: null,
      loading: false,
      success: false,
    };
  },
  methods: {
    async submit() {
      try {
        this.error = null;
        this.loading = true;

        await post({
          data: this.data,
          endpoint: this.endpoint,
        });

        this.loading = false;
        this.success = true;
      } catch (error) {
        this.error = error;
        this.loading = false;
        this.success = false;
      }
    },
  },
  render() {
    return this.$scopedSlots.default({
      // Data
      error: this.error,
      loading: this.loading,
      success: this.success,
      // Methods
      submit: this.submit,
    });
  },
};

Let’s take a closer look at the code of the FormFrame renderless component you can see above. At the top we import a post method from the api package, you can take a look at the code in the GitHub repository. This method is using the new Fetch API but you can also use axios or some other library if you want.

The component takes two properties: data and endpoint. Via the data property, an object containing all the data we want to send to our backend is passed. The endpoint property takes the API endpoint to which the data should be sent.

The submit() method immediately (re)sets the error and the loading state and executes the post request. If it succeeds the loading state is reset and the success state is set to true. If an error occurs it is catched and the error state is set.

Last but not least, the render() function passes all relevant properties to the components default slot and returns the rendered default slot. The component does not render any markup itself – hence the name “renderless”.

Building a simple feedback form

Now we can use the renderless FormFrame component to build a simple feedback form.

<template>
  <FormFrame
    :data="formData"
    endpoint=".netlify/functions/feedback"
  >
    <div
      slot-scope="{ error, loading, success, submit }"
      class="FormFeedback"
    >
      <p v-if="success">
        Thank you!
      </p>
      <form
        v-else
        class="o-vertical-spacing"
        @submit.prevent="submit"
      >
        <label class="FormFeedback__label">
          Name
          <input
            v-model="formData.name"
            class="FormFeedback__field"
          >
        </label>
        <label class="FormFeedback__label">
          Message
          <textarea
            v-model="formData.message"
            class="FormFeedback__field"
          />
        </label>
        <p
          v-if="error"
          class="FormFeedback__error"
        >
          An error has occurred, please try again.
        </p>
        <button :disabled="loading">
          <template v-if="loading">
            Sending ...
          </template>
          <template v-else>
            Submit
          </template>
        </button>
      </form>
    </div>
  </FormFrame>
</template>

<script>
import FormFrame from './frames/FormFrame';

export default {
  name: 'FormFeedback',
  components: {
    FormFrame,
  },
  data() {
    return {
      formData: {
        message: '',
        name: '',
      },
    };
  },
};
</script>

<style lang="scss">
@import '../assets/scss/settings/**/*';

.FormFeedback__field {
  width: 100%;
  padding: setting-spacing(m);
  border: 1px solid #c5c5c5;
  border-radius: 0.25em;
}

.FormFeedback__label {
  display: block;
}

.FormFeedback__error {
  color: red;
}
</style>

This is a very simple implementation of a form with just two fields: name and message. Thanks to the properties provided by the FormFrame component via slot-scope="{ error, loading, success, submit }" we’re able to conditionally render an error message or a success message. Additionally we can easily disable the submit button while the the request is loading. The submit() method which is passed via slot-scope is triggered by the @submit event on the <form> tag.

Right at the beginning of the code snippet you can see that we are using a Netlify Functions endpoint to handle our data on the backend – let’s take a look at how we can implement this endpoint.

Backend

I’ve already written about how to set up Netlify Serverless Functions in a Vue.js project. So I’m not going into much detail on how to do that in this article. You can also take a look at this commit to see the changes needed in order to integrate a Netlify Functions build step into your Vue.js project.

In the following code snippet you can see a simple implementation of how to create an endpoint for your form request using Netlify Serverless Functions.

// src-functions/feedback.js
exports.handler = async (event, context, callback) => {
  try {
    // Do not handle requests if the request
    // type is something other than `POST` or
    // if the request body is empty.
    if (event.httpMethod !== 'POST' || !event.body) {
      callback(null, {
        statusCode: 400,
        body: JSON.stringify({ status: 'Bad Request' }),
      });
      return;
    }

    const {
      message,
      name,
    } = JSON.parse(event.body);

    // This is the place to handle
    // the submitted data.
    // For example:
    // await sendEmail({ message, name });
    // or
    // await saveToDb({ message, name });

    callback(null, {
      statusCode: 200,
      body: JSON.stringify({ status: 'success' }),
    });
  } catch (error) {
    callback(null, {
      statusCode: 500,
      body: JSON.stringify({ status: 'error' }),
    });
  }
};

What you can see above is a very basic implementation. You should add some validation rules and sanitize the incoming data before sending it to your database.

Integrating reCAPTCHA v3

Next up, we want to prevent spam bots from flooding our email inbox or database with spam submissions. Let’s take a look at how we can integrate Googles reCAPTCHA v3 to prevent that.

In order to use it you have to register for reCAPTCHA v3. You get two keys: one public key which you must use as a parameter for loading the reCAPTCHA script (as you can see below) and a private one which we gonna use in the serverless function.

     <title>Building a Renderless reCAPTCHA v3 Form Component with Vue.js</title>
     <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <script src='https://www.google.com/recaptcha/api.js?render=6LelBIcUAAAAAGkihBXg7vWPXV5QJaj0bE_qeX1e'></script>
   </head>
   <body>

Above you can see how to update the public/index.html file in order to load the reCAPTCHA script.

 import { post } from '../../utils/api';
 
+// Public token.
+const RECAPTCHA_TOKEN = '6LelBIcUAAAAAGkihBXg7vWPXV5QJaj0bE_qeX1e';
+
 export default {
   props: {
     data: {
         this.error = null;
         this.loading = true;
 
+        const token = await this.recaptchaToken();
         await post({
-          data: this.data,
+          data: { ...this.data, token },
           endpoint: this.endpoint,
         });

         this.loading = false;
         this.success = true;
       } catch (error) {
         this.error = error;
         this.loading = false;
         this.success = false;
       }
     },
+    recaptchaToken() {
+      return new Promise((resolve) => {
+        grecaptcha.ready(async () => {
+          const token = await grecaptcha.execute(RECAPTCHA_TOKEN);
+          resolve(token);
+        });
+      });
+    },
   },
   render() {
     return this.$scopedSlots.default({

Above you can see the changes we made to the FormFrame component. We implemented a new recaptchaToken() method which fetches a new reCAPTCHA token via the reCAPTCHA API. We add this token to the data object which we send to our backend.

+import axios from 'axios';
+
+const { RECAPTCHA_SECRET } = process.env;
+const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
+const RECAPTCHA_SCORE_THRESHOLD = 0.5;
+
+function isHuman(token) {
+  const endpoint = `${RECAPTCHA_VERIFY_URL}?response=${token}&secret=${RECAPTCHA_SECRET}`;
+  return axios.post(endpoint)
+    .then(({ data }) => data.score > RECAPTCHA_SCORE_THRESHOLD);
+}
+
 exports.handler = async (event, context, callback) => {
   try {
     // Do not handle requests if the request

In the src-function/feedback.js script, the first thing we do is to import the axios library. We can’t use our own api utility because it’s based on the Fetch API which is not supported by Node.js.

npm install axios

Next we get the reCAPTCHA secret from the process.env environment variable because we don’t want to commit the secret to our Git repository. The RECAPTCHA_SCORE_THRESHOLD defines the threshold for declaring a form submission to be human or not. It is a value between 0 and 1 where 0 means almost certainly a bot and 1 is almost certainly a human.

The isHuman() function takes the token we previously fetched in the FormFrame component and sends it to the reCAPTCHA API in order to verify the request and to get a score. If the score is larger than what we’ve defined in RECAPTCHA_SCORE_THRESHOLD we declare the user a human.

     const {
       message,
       name,
+      token,
     } = JSON.parse(event.body);
 
-    // This is the place to handle
-    // the submitted data.
-    // For example:
-    // await sendEmail({ message, name });
-    // or
-    // await saveToDb({ message, name });
+    if (await isHuman(token)) {
+      // This is the place to handle
+      // the submitted data.
+      // For example:
+      // await sendEmail({ message, name });
+      // or
+      // await saveToDb({ message, name });
+    }
 
     callback(null, {
       statusCode: 200,

In this diff you can see how we can make sure to only handle the form submission if it is most likely made be a real human being. We send a 200 status code, no matter what, to keep spam bots from knowing if their submission was successful or not.

Wrapping it up

Renderless components are the gold standard when it comes to reusability of Vue.js components. In contrast to mixins, for example, they’re much more transparent.

The implementation we’ve built in this article is a very basic one but I think it serves as a decent starting point for building more complex solutions.


Do you want to learn how to build advanced Vue.js applications?

Register for the Newsletter of my upcoming book: Advanced Vue.js Application Architecture.



Do you enjoy reading my blog?

You can buy me a ☕️ on Ko-fi!