Making Next.js forms bot-proof using ReCaptcha v3

If you go through the trouble setting up a contact-form on your website you might as well take the extra mile, or in this case the extra 20 minutes to make it bot-proof by using the Google ReCaptcha v3 service. In this article I'll explain what steps I took and the code needed to protect the contact form on this website.

Step one: register your website

First you need to register your website in order to acquire the keys needed to communicate with the service:

Google reCAPTCHA v3 registration Google reCAPTCHA v3 keys

Step two: have the keys set up

As this website is built with Next.js 13 the environment variables for the client-side need to be present on build-time, meaning I have set them up in a CI/CD -variable (of type file) in my GitLab installation and reference them as follows:

build app: stage: build cache: paths: - node_modules artifacts: paths: - .next before_script: - cp "$ENV_FILE_PATH" .env script: - npm run build

The contents for the .env -file is written next to the source code and will then look similar to this:

NEXT_PUBLIC_RECAPTCHA_SITE_KEY=<nope> RECAPTCHA_SECRET_KEY=<even-more-nope>

As you can see, the first environment variable name is prefixed with NEXT_PUBLIC_, which is replaced build-time in the source code by Next.js and is therefore available in the client-side code as well. The secret-key on the other hand must only be available to the server-side code, which calls the reCAPTCHA -service from the endpoint we use to handle the contact-form request.

Step three: the backend-handler for the form

Lets create an API-endpoint for handling the form submit -request. We'll have a file pages/api/contact.ts for that purpose:

import {NextApiRequest, NextApiResponse} from "next"; import {Response} from "next/dist/compiled/@edge-runtime/primitives/fetch"; type RecaptchaResponse = { success: boolean // whether this request was a valid reCAPTCHA token for your site score: number // the score for this request (0.0 - 1.0) action: string // the action name for this request (important to verify) challenge_ts: string // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ) hostname: string // the hostname of the site where the reCAPTCHA was solved "error-codes": any[] // optional } const verifyRecaptcha = async (token:string): Promise<RecaptchaResponse> => { const secret = process.env.RECAPTCHA_SECRET_KEY; const url = `https://www.google.com/recaptcha/api/siteverify?secret=${secret}&&response=${token}`; return await fetch(url).then((response: Response) => response.json()); } export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> { try { const token = req.body.gRecaptchaToken; const response = await verifyRecaptcha(token); if (!response.success) { console.log("Google RECAPTCHA v3 returned a failure with the following response:" + response); return res.status(500).json({status: "error", message: "Something went wrong, please try again."}); } console.log("Google RECAPTCHA v3 returned a success with score of " + response.score); // see https://developers.google.com/recaptcha/docs/v3#interpreting_the_score for more information if (response.score < 0.5) { return res.status(403).json({status: "error", message: "reCAPTCHA score too low"}); } } catch (error) { return res.status(500).json({status: "error", message: "Something went wrong, please try again!"}); } // Here you do whatever you want to do with the actual form data and don't forget to return an "ok" response: res.status(202).end(); }

So what we have here is the verifyRecaptcha-function that is just a helper to make the actual call to the reCAPTCHA -service; It will return a response which is modeled above with the type RecaptchaResponse. This is just a brief example and is lacking a lot of error-checking with the fetch-call etc as it's beyond the scope of this article.

The handler itself is the "controller" of the API-endpoint and will take care of the request against route /api/contact. There the token from the form (generated by the reCAPTCHA frontend-library) is checked against Google's endpoint, and if the response is ok and the score high enough (0.5 in this case) we're good to go.

Step five: the reCAPTCHA frontend integration and the form itself

I used the react-google-recaptcha-v3 -package for managing the settings and dependencies for reCAPTCHA. It is used by wrapping your components inside it, so my pages/_app.tsx looks roughly like this:

import type {AppProps} from 'next/app' import React from "react"; import {GoogleReCaptchaProvider} from "react-google-recaptcha-v3"; const RECAPTCHA_SITE_KEY = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY ?? ''; export default function App({Component, pageProps}: AppProps) { return <> <GoogleReCaptchaProvider reCaptchaKey={RECAPTCHA_SITE_KEY} scriptProps={{ async: false, defer: false, appendTo: 'body', }} > <div className="page-container"> <Component {...pageProps} /> </div> </GoogleReCaptchaProvider> </> }

The actual contact-page is located at pages/contact.tsx and looks similar to this:

import React, {useCallback, useState} from "react"; import {useGoogleReCaptcha} from "react-google-recaptcha-v3"; export default function ContactPage(): JSX.Element { const [isSending, setIsSending] = useState<boolean>(false) const [hasSubmitted, setHasSubmitted] = useState<boolean>(false) const [email, setEmail] = useState<string>("") const [firstName, setFirstName] = useState<string>("") const [lastName, setLastName] = useState<string>("") const [message, setMessage] = useState<string>("") const { executeRecaptcha } = useGoogleReCaptcha(); const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() setIsSending(true) if (!executeRecaptcha) { return; } executeRecaptcha("contactFormSubmit") .then((gRecaptchaToken) => { const data = { email, firstName, lastName, message, gRecaptchaToken, }; try { fetch('/api/contact', { method: 'POST', headers: { Accept: "application/json, text/plain, */*", "Content-Type": "application/json", }, body: JSON.stringify(data), }).then(response => { if (!response.ok) { throw new Error(`Invalid response: ${response.status}`); } setHasSubmitted(true) setIsSending(false) }) } catch (err) { alert("An error occurred, please try again later,"); } }); }, [executeRecaptcha, setIsSending, email, firstName, lastName, message] ); return ( <main> {hasSubmitted ? <div>Thanks for contacting us, we will get back to you soon!</div> : <div className={`form-container`}> <form onSubmit={onSubmit}> <fieldset disabled={isSending || !executeRecaptcha}> <div className="row"> <div className="col-25"> <label htmlFor="email">Email</label> </div> <div className="col-75"> <input type="email" id="email" name="email" value={email} onChange={(e) => setEmail(e.target.value)} required/> </div> </div> <div className="row"> <div className="col-25-dual"> <label htmlFor="firstName">First name</label> </div> <div className="col-25-dual"> <input type="text" id="firstName" name="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)}/> </div> <div className="col-25-dual"> <label htmlFor="lastName">Last name</label> </div> <div className="col-25-dual"> <input type="text" id="lastName" name="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)}/> </div> </div> <div className="row"> <div className="col-25"> <label htmlFor="message">Message</label> </div> <div className="col-75"> <textarea id="message" name="message" rows={10} value={message} onChange={(e) => setMessage(e.target.value)}/> </div> </div> <div className="row"> <input type="submit" value="Send"/> </div> </fieldset> <small>This site is protected by reCAPTCHA and the Google <a href="https://policies.google.com/privacy">Privacy Policy</a> and <a href="https://policies.google.com/terms">Terms of Service</a> apply.</small> </form> </div> } </main> ) }

Nothing special here: the state of the form and it's fields is managed through React useState -hooks, the token for the reCAPTCHA is obtained by using the useGoogleReCaptcha -hook and sent along with the form fields to the backend with simple fetch -call.

The final touch

The reCAPTCHA badge is rather large, especially viewed on mobile devices and since we use it only on one page here it feels it takes a bit too much screen-realestate. Thankfully you can hide it as long as you include the T&C's from Google as demonstrated between the small-tags below the form. Now you can just add the following css snippet to your stylesheet and the badge is no more:

.grecaptcha-badge { visibility: hidden; }

Conclusion

As you can see it doesn't take that much time nor effort to integrate Google reCAPTCHA service on your website, but it'll probably save you a lot of time and effort by not having to go through more spam in your inbox.