How to add Google Recaptcha v3 to your Remix JS contact form

How to add Google Recaptcha v3 to your Remix JS contact form

Whenever we have a contact form in our application it's a good idea to implement Google Recaptcha to prevent spam messages from coming through.

Without further ado let's jump straight into it!

Project setup

Let's create a clean Remix JS project> For the ease of setup I am going to use an unstable Vite plugin (it might not be when you are reading this post)

npx create-remix@latest --template remix-run/remix/templates/unstable-vite

Form Setup

For simplicity, we will set our form in app/routes/index.tsx

import type { MetaFunction } from "@remix-run/node";
import { Form } from "@remix-run/react";

export const meta: MetaFunction = () => {
  return [{ title: "Remix JS + Recaptcha v3" }, { name: "description", content: "Welcome to Remix!" }];
};

export default function Index() {
  return (
    <div className="page_wrapper">
      <Form method="post" className="form">
        <div className="form_field">
          <label htmlFor="name">Name</label>
          <input type="text" name="name" id="name" />
        </div>
        <button type="submit">Submit</button>
      </Form>
    </div>
  );
}

As you can see there isn't much, just a single input for the name and submit button, let's add some base styles to make it look somewhat decent :)

Create app/styles.css file

/* app/styles.css */

* {
  margin: 0;
  padding: 0;
}

.page_wrapper {
  height: 100vh;
  width: 100vw;
  display: flex;
  justify-content: center;
  align-items: center;
}

.form {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 1rem;
}

.form_field {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.form_field label {
  font-size: 1.2rem;
}

.form_field input {
  font-size: 1rem;
  padding: 0.5rem;
}

and import it in _index.tsx

import "~/styles.css";

We should be presented with something like this:

Pretty right? Not really :P but functionality is the key here.

Implementing Recaptcha

First thing let's register for Recaptcha to obtain the Site Key and Secret Key, go here and register domains that will use it(in our case it's just localhost) and save your keys.

Now create a file .env in the root of your project and paste in both of your keys, we will need them.

//.env

RECAPTCHA_SITE_KEY = '[your_site_key]'
RECAPTCHA_SECRET_KEY = '[your_secret_key]'

react-google-recaptcha-v3 setup

We will use react-google-recaptcha-v3 and wrap our entire app with GoogleReCaptchaProvider.

Take a look at our updated root.tsx file

import { LoaderFunction } from "@remix-run/node";
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, json, useLoaderData } from "@remix-run/react";
import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3";

export const loader: LoaderFunction = async () => {
  return json({
    ENV: {
      RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
    },
  });
};

export default function App() {
  const { ENV } = useLoaderData<typeof loader>();

  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <GoogleReCaptchaProvider
          reCaptchaKey={ENV.RECAPTCHA_SITE_KEY}
          scriptProps={{
            async: false,
            defer: true,
            appendTo: "head",
            nonce: undefined,
          }}
        >
          <Outlet />
        </GoogleReCaptchaProvider>
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

As you can see we need to import GoogleReCaptchaProvider and wrap <Outlet /> component in it. Because we have to pass our SITE_KEY into it, we also need to use loader function and return our SITE_KEY from it.

getRecaptchaScore function

Let's create a new file: app/utils/getRecaptchaScore.tsx

export async function getRecaptchaScore(token: string, key: string): Promise<boolean> {
  let res;
  const captchData = new URLSearchParams({
    secret: key,
    response: token,
  });

  try {
    // Sending a POST request to the reCAPTCHA API using fetch
    res = await fetch("https://www.google.com/recaptcha/api/siteverify", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: captchData,
    });

    // Parsing the JSON response
    res = await res.json();
  } catch (e) {
    // Handling errors if the reCAPTCHA verification fails
    console.log("recaptcha error:", e);
  }

  console.log(res.score); // let's console log the score

  // Checking the result of the reCAPTCHA verification
  if (res && res.success && res.score > 0.5) {
    // If verification is successful, continue with form submission
    return true;
  } else {
    // If verification fails, return an error message
    return false;
  }
}

We will use this function to reCaptcha validate our contact form submission, it takes in token and key as its attributes and makes a request to google recaptcha API which will return us success and score which we will then use to return either true if we think we should pass this form submission or false if we think it's a robot submitting our form!

Adding reCaptcha validation to our form

Let's go back to our _index.tsx file

First, we will use useGoogleReCaptcha hook provided by react-google-recaptcha-v3 library in order to get our token(which we need to pass to our getRecaptchaScore function), then using useCallback and useEffect hook save our token in the captchaToken state. More on this in react-google-recaptcha-v3 documentation.

//_index.tsx

export default function Index() {

  // Token state
  const [captchaToken, setCaptchaToken] = useState<string | null>(null);

  // custom hook from reCaptcha library
  const { executeRecaptcha } = useGoogleReCaptcha();

  const handleReCaptchaVerify = useCallback(async () => {
    if (!executeRecaptcha) {
      return;
    }

    const token = await executeRecaptcha("yourAction");
    setCaptchaToken(token);
  }, [executeRecaptcha]);

  // useEffect that will execute out token setting callback function
  useEffect(() => {
    handleReCaptchaVerify();
  }, [handleReCaptchaVerify]);

  return (
    ...
  )
}

Now we need to create an action function and a way to pass our token to it. We will do it by adding a hidden input field to our Form. In our new action function, we can get our form data which has our token in it, and pass it into our getRecaptchaResult util function.

Based on its result we will either pass and redirect to the thank you page or prevent form submission and return a message which we can then display below our form.

// _index.tsx

...

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();

  // form validation logic here

  const token = formData.get("_captcha") as string;
  const key = process.env.RECAPTCHA_SECRET_KEY as string;

  // validate captcha util function
  const recaptchaResult = await getRecaptchaScore(token, key);
  console.log(recaptchaResult); // result of our recaptcha validation

  if (!recaptchaResult) {
      // your contact form submission code here
      return redirect("/thank-you");
  }

  return json({ message: "You are a robot!" });
};

export default function Index() {
  // get data from action hook
  const actionData = useActionData<typeof action>();

  const [captchaToken, setCaptchaToken] = useState<string | null>(null);

  const { executeRecaptcha } = useGoogleReCaptcha();

  const handleReCaptchaVerify = useCallback(async () => {
    if (!executeRecaptcha) {
      return;
    }

    const token = await executeRecaptcha("yourAction");
    setCaptchaToken(token);
  }, [executeRecaptcha]);

  useEffect(() => {
    handleReCaptchaVerify();
  }, [handleReCaptchaVerify]);

  return (
    <div className="page_wrapper">
      <Form method="post" className="form">
        {captchaToken ? <input type="hidden" name="_captcha" value={captchaToken}></input> : null}
        <div className="form_field">
          <label htmlFor="name">Name</label>
          <input type="text" name="name" id="name" />
        </div>

        <button type="submit" onSubmit={() => handleReCaptchaVerify}>
          Submit
        </button>
        {actionData?.message ? <p>{actionData.message}</p> : null}
      </Form>
    </div>
  );
}

And that's it! Working recaptcha validation in your Remix Contact Form.

Of course, other things would need to be implemented here like form validation and actual contact form submission logic but those parts are specific to the type of form or your backend that will handle the form submission itself.

Check on GitHub

Example repo here.

Hope it was helpful and see you at the next one! :)