As with most Google things, implementing a simple ReCAPTCHA comes with challenges:
- Google's documentation is all over the place
- 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:
- Generate a "token" client-side using Google's reCAPTCHA library and the public key
- Pass the token to the backend (e.g.
/api/subscribe
) - Validate the generated token (that lasts 2 minutes) with the private key
- 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>
);
}