Invisible ReCAPTCHA with Next.js and TypeScript (deferred script)

This post walks through how to enable ReCAPTCHA in a performant way with the Next.js built-in script component

Written by Zach

Last Updated: Mar 29, 2024

As with most Google things, implementing a simple ReCAPTCHA comes with challenges:

  1. Google's documentation is all over the place
  2. Google's scripts always seem to hurt page speed scores (ironic)

Here's one of my sites using the default "out of the box" Captcha config:

In this post, we're going to talk about how we can fix this and implement an invisible captcha for your email signup forms that has a decent UX and is still performant.

CAPTCHA validation flow

The flow is pretty straightforward:

  1. Generate a "token" client-side using Google's reCAPTCHA library and the public key
  2. Pass the token to the backend (e.g. /api/subscribe)
  3. Validate the generated token (that lasts 2 minutes) with the private key
  4. If successful validation, add your user's email to mailing list (or other data source). If failure, handle error however you need to.

Get Captcha API Keys

Using the Google Recaptcha Admin Dashboard, create a new site.

For this tutorial, I'm using reCAPTCHA v2 with the "Invisible reCAPTCHA badge" setting.

Once you've got your keys, throw them in your .env.local file:

NEXT_PUBLIC_RECAPTCHA=<key>
RECAPTCHA_SECRET=<secret>

Write backend reCAPTCHA validator

Since we're dealing with Next.js, you can create an API route called /api/subscribe, which will accept the following body. I'm using Zod for validation here.

Note, the endpoint that I'm hitting, https://www.google.com/recaptcha/api/siteverify, is documented in Google's reCAPTCHA docs, but is very hidden.

import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";

type Response = {
  status: string;
  error?: string;
};

const Input = z.object({
  email: z.string().email(),
  captcha: z.string(),
});

const verifyEndpoint = "https://www.google.com/recaptcha/api/siteverify";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  if (req.method === "POST") {
    // Validate request
    const data = Input.parse(req.body);
    const { email, captcha } = data;

    // Validate a "token" that the client-side reCAPTCHA script generated for the user
    const captchaResponse = await fetch(verifyEndpoint, {
      method: "POST",
      headers: { "Content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        secret: process.env.RECAPTCHA_SECRET, // See prior section
        response: captcha, // the user's generated "Captcha" token
      }),
    }).then((res) => res.json());

    // If the verification fails, return 500x code
    if (!captchaResponse.success) {
      return res
        .status(500)
        .json({ status: "error", error: captchaResponse["error-codes"][0] });
    }

    // If verification succeeds, create a contact with your email provider
    // (this is just a mock)
    await myEmailProvider.addContact(email);

    res.status(200).json({ status: "ok" });
  } else {
    res.status(404); // Unhandled HTTP request
  }
}

Generate a reCAPTCHA token client-side

With our endpoint setup, we can create a barebones form to capture an email and run an "invisible" reCAPTCHA challenge. I'm using React Hook Form here as it's my go-to form library.

Install deps:

yarn add react-hook-form react-google-recaptcha

Finally, here is the EmailForm.tsx component:

import { useRef, useState } from "react";
import { useForm } from "react-hook-form";

import Captcha from "react-google-recaptcha";

export default function EmailForm() {
  const captchaRef = useRef<Captcha>(null);

  const { register, handleSubmit } = useForm({
    defaultValues: { email: "", captcha: "" },
  });

  return (
    <form
      onSubmit={handleSubmit(async (data) => {
        // Get the token
        const captcha = await captchaRef.current?.executeAsync();

        // Pass to Next.js API Route
        const res = await fetch("/api/subscribe", {
          method: "POST",
          body: JSON.stringify({ email: data.email, captcha }),
          headers: { "Content-type": "application/json" },
        });
      })}
    >
      <Captcha
        ref={captchaRef}
        size="invisible"
        sitekey={process.env.NEXT_PUBLIC_CAPTCHA!}
      />

      <label htmlFor="email-address" className="sr-only">
        Email address
      </label>
      <input
        type="email"
        autoComplete="email"
        placeholder="Enter your email"
        {...register("email", {
          required: true,
          pattern: {
            value: /\S+@\S+\.\S+/,
            message: "Entered value does not match email format",
          },
        })}
      />

      <button type="submit">Subscribe</button>
    </form>
  );
}

This works fine, but you're going to end up with Google's CAPTCHA script loading wherever this component is imported, which is likely going to be every page (if you throw this in a <footer /> or something similar).

We can improve page speed scores by deferring the initialization of the script until the user starts typing in the email field.

Optimize the reCAPTCHA script

Here is the same component, but with "lazy loading" of the script:

import { useRef, useState } from "react";
import { useForm } from "react-hook-form";

import Captcha from "react-google-recaptcha";

export default function EmailForm() {
  const captchaRef = useRef<Captcha>(null);

  const [captchaEnabled, setCaptchaEnabled] = useState(false);

  const { register, handleSubmit } = useForm({
    defaultValues: { email: "", captcha: "" },
  });

  // Watch for changes to email value and enable the CAPTCHA script
  const [email] = watch(["email"]);

  // Does this pattern look weird to you? See - https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
  if (email && !captchaEnabled) {
    setCaptchaEnabled(true);
  }

  return (
    <form
      onSubmit={handleSubmit(async (data) => {
        // Get the token
        const captcha = await captchaRef.current?.executeAsync();

        // Pass to Next.js API Route
        const res = await fetch("/api/subscribe", {
          method: "POST",
          body: JSON.stringify({ email: data.email, captcha }),
          headers: { "Content-type": "application/json" },
        });
      })}
    >
      {/* Only enable CAPTCHA script if user has typed something */}
      {captchaEnabled && (
        <Captcha
          ref={captchaRef}
          size="invisible"
          sitekey={process.env.NEXT_PUBLIC_CAPTCHA!}
        />
      )}

      <label htmlFor="email-address" className="sr-only">
        Email address
      </label>
      <input
        type="email"
        autoComplete="email"
        placeholder="Enter your email"
        {...register("email", {
          required: true,
          pattern: {
            value: /\S+@\S+\.\S+/,
            message: "Entered value does not match email format",
          },
        })}
      />

      <button type="submit">Subscribe</button>
    </form>
  );
}