Integrating reCAPTCHA v3 with form in a Next.js application

reCAPTCHA is a free service from Google that helps protect websites from spam and abuse, and of course, it’s a type of CAPTCHA, which stands for Completely Automated Public Turing test to tell Computers and Humans Apart. A CAPTCHA is a test that's easy for humans to solve, but hard for “bots” and other malicious software to figure out. More likely than not, this is something you've come across in the past while browsing your favorite websites.

As mentioned in my previous post, it’s essential to have a balance between security and user experience. Some CAPTCHA systems can be difficult for users to solve, which might lead to frustration and abandonment of the action they're trying to complete on your site. Because of that, it’s essential to choose a CAPTCHA system that provides adequate security and is also user-friendly. Luckily, reCAPTCHA has evolved from asking users to decipher hard-to-read text or match images (v1), to analyzing cookies and canvas rendering to decide whether to show a challenge or not (v2), to running automatically when a page is loaded or a button is clicked, not interrupting the users at all (v3), also known as invisible reCAPTCHA.

In this guide, we'll aim for the best user experience, while making our form secure from spam, so we'll use reCAPTCHA v3. So, without further ado, let's get started!

In a way, this guide is a continuation of my previous post: Step-by-step guide for creating a contact form using Next.js and Nodemailer, so we're going to be using the codebase created for that guide, but if you already have a form you want to integrate with reCAPTCHA, feel free to jump to step 2. If you want to create a contact form from scratch, it might be a good idea to start here first, though.

1. Set up Next.js app and form

Open a terminal window, point it to your desired work directory, and clone the blog-tutorials repo:

git clone https://github.com/andresebr/blog-tutorials.git

We're going to be editing our contact form codebase, so move to that directory:

cd nextjs-contact-form

From now on, nextjs-contact-form will be your working directory. If you want to check it out, run:

npm install npm run dev

Once the process completes, you can see the form component in action via http://localhost:3000.

Important: You need to edit the .env file to enable sending messages from the form. Check here for information about how to get SMTP host credentials if you don't have any in hand.

2. Preparation

Let's start by adding react-google-recaptcha-v3 to your installed dependencies:

npm install react-google-recaptcha-v3

This library will facilitate integrating reCAPTCHA with our application, but first, we need to create a reCAPTCHA key for your web application domain.

Getting a reCAPTCHA key

First, go to the Google reCaptcha portal. From there, click on v3 Admin Console. After you log in with your Google account, you'll access the Google reCAPTCHA dashboard where you can register a new site.

For this guide, we won't use reCAPTCHA Enterprise; in the step for creating a new site, make sure to click on Switch to create a classic key. After that, fill out the form with a label for identifying your site in the dashboard, select reCAPTCHA v3 in the reCAPTCHA type section, and make sure you add the domains where you'll be using the application. Since we're testing locally, add localhost and 127.0.0.1 to that list.

Once that information is submitted, you'll get two keys, a site key and a secret key. Copy, and store them in a secure place. We'll put those keys in the .env file next.

⚠ Important:  For security reasons, have separate registered sites/keys for development and production.

Update the .env file

Let's add the keys we just obtained to the .env file. This is how it should look after that change:

.env

# .env file sample. # TODO: Modify values with your actual credentials. # Messages sent from the contact form will be sent from this address. OUTBOX_EMAIL=youroutboxemail@address.com OUTBOX_EMAIL_PASSWORD=SaMpl3Password # Email address where we want to receive the messages sent from the contact form. It can be the same as your OUTBOX_EMAIL. INBOX_EMAIL=yourinboxemail@address.com # reCAPTCHA keys. NEXT_PUBLIC_RECAPTCHA_SITE_KEY=YourReCAPTCHAClientKey RECAPTCHA_SECRET_KEY=YourReCAPTCHASecretKey

Important: We need the site key to be accessible in the browser. For that reason, we use the prefix NEXT_PUBLIC_. You can read more about this here.

3. Integrate reCAPTCHA v3

Front-end modifications

Let's start by creating a CaptchaProvider component. Under /components add captcha-provider.tsx.

components/captcha-provider.tsx

import { ReactNode } from "react"; import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3"; interface CaptchaProviderProps { children: ReactNode; } export default function CaptchaProvider({ children }: CaptchaProviderProps) { const recaptchaKey: string | undefined = process?.env?.NEXT_PUBLIC_RECAPTCHA_SITE_KEY; return ( <GoogleReCaptchaProvider reCaptchaKey={recaptchaKey ?? "NOT DEFINED"} scriptProps={{ async: false, defer: false, appendTo: "head", nonce: undefined, }} > {children} </GoogleReCaptchaProvider> ); }

Here, we read NEXT_PUBLIC_RECAPTCHA_SITE_KEY, needed to set up the Google reCAPTCHA v3 provider for the application. This reusable component provides reCAPTCHA functionality to all its child components. In this case, it'll be used to wrap our form component in pages/index.tsx.

pages/index.tsx

import CaptchaProvider from "@/components/captcha-provider"; import ContactForm from "@/components/contact-form"; import styles from "@/styles/Home.module.css"; import Head from "next/head"; export default function Home() { return ( <> <Head> <title>Contact form demo</title> <meta name="description" content="Next.js contact form demo" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" href="/favicon.ico" /> </Head> <main className={styles.main}> <CaptchaProvider> <ContactForm /> </CaptchaProvider> </main> </> ); }

Next, we need to generate a captchaToken that will be passed in the API request when the form gets submitted. For this, we update the ContactForm component code found in components/contact-form.tsx:

components/contact-form.tsx

import handleSendEmail from "@/service/contact"; import { FormEvent, useState } from "react"; import { useGoogleReCaptcha } from "react-google-recaptcha-v3"; import contactFormStyles from "./contact-form.module.css"; export default function ContactForm() { const [responseMessage, setResponseMessage] = useState<string>(); const { executeRecaptcha } = useGoogleReCaptcha(); async function onSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const eventData = new FormData(event.currentTarget); // Clear response. setResponseMessage(undefined); // Check if executeRecaptcha is available. if (!executeRecaptcha) { setResponseMessage( "Execute reCAPTCHA not available yet likely meaning reCAPTCHA key not set." ); } else { // If available. Try getting captcha token. executeRecaptcha("enquiryFormSubmit").then(async (captchaToken) => { const data = { name: eventData.get("name")?.toString(), email: eventData.get("email")?.toString(), message: eventData.get("message")?.toString(), }; const { name, email, message } = data; if (name && email && message) { // Send generated captchaToken so it can processed in the server. const response = await handleSendEmail({ name, email, message, captchaToken, }); if (response.error) { setResponseMessage(response.error); } else { setResponseMessage(response.message); } } else { setResponseMessage("All fields are required."); } }); } } return ( <> <div className={contactFormStyles.container}> <h1 className={contactFormStyles.header}>Contact Me!</h1> <form onSubmit={onSubmit}> <label htmlFor="name">Name</label> <input type="text" title="Name" name="name" id="name" required className={contactFormStyles.formField} /> <label htmlFor="email">Email</label> <input type="email" title="Email" name="email" id="email" required className={contactFormStyles.formField} /> <label htmlFor="message">Message</label> <textarea rows={10} title="Message" name="message" id="message" required className={contactFormStyles.formField} ></textarea> <input type="submit" title="Send" value="Send" className={contactFormStyles.submitButton} /> </form> {responseMessage && ( <p className={contactFormStyles.alert}>{responseMessage}</p> )} </div> </> ); }

The capcthaToken is now being passed to handleSendEmail alongside the form data. Because of this change, we need to modify the function in service/contact.ts to add the new parameter definition.

service/contact.ts

const API_ENDPOINT = "/api/contact"; export default async function handleSendEmail(data: { name: string; email: string; message: string; captchaToken: string; }): Promise<{ data?: unknown; message?: string; error?: string }> { const response = await fetch(API_ENDPOINT, { method: "POST", headers: { Accept: "application/json, text/plain, */*", "Content-Type": "application/json", }, body: JSON.stringify(data), }); return response.json(); }

And with those modifications in place, we're ready to move to the back end.

Back-end modifications

We'll modify the API endpoint code in pages/api/contact.ts to validate the captcha token passed in the request. Here, we'll use the RECAPTCHA_SECRET_KEY defined in the .env file.

pages/api/contact.ts

import { NextApiRequest, NextApiResponse } from "next"; import nodemailer from "nodemailer"; const EMAIL_REGEX = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/; function isEmpty(inputString?: string) { return !inputString || inputString.trim().length === 0; } export default async function handler( req: NextApiRequest, res: NextApiResponse ) { try { const { method, body } = req; // Get captchaToken from body. const { captchaToken } = body; if (method === "POST") { // Call method for validating the captcha. const captchaValidationResponse = await validateCaptcha(captchaToken); console.log( body.captchaToken.slice(0, 10) + "...", captchaValidationResponse ); // Validate that capctha token has been validated. // Score check can be tweaked depending on how strict you want this check to be. if ( captchaValidationResponse && captchaValidationResponse.success && captchaValidationResponse.score > 0.5 ) { // Send email if validations pass. await sendMail(body); res.status(200).send({ message: "Message successfully sent" }); } else { res.status(405).json({ message: "Bots are not allowed" }); } } else { // Error if method is not correct. res.status(405).end(`Method ${method} not allowed`); } } catch (err) { // Catch errors thrown in the process. res.status(400).json({ error: err || "There was an error sending the message. Please try again later", }); } } async function validateCaptcha(captchaToken: string) { // Verify that both captchaToken and RECAPTCHA_SECRET_KEY were set. if (captchaToken && process.env.RECAPTCHA_SECRET_KEY) { console.log("Validating captcha..."); // Validate captcha. const response = await fetch( `https://www.google.com/recaptcha/api/siteverify?${new URLSearchParams({ secret: process.env.RECAPTCHA_SECRET_KEY, response: captchaToken, })}`, { method: "POST", } ); return response.json(); } else { // Show in the console if needed values are not set. console.log( "captchaToken or RECAPTCHA_SECRET_KEY not set. Captcha could not be validated" ); } } async function sendMail(data: { name: string; email: string; message: string; }) { const { name, email, message } = data; await new Promise((resolve, reject) => { if (!isEmpty(name) && !isEmpty(message) && !isEmpty(email)) { if (email && EMAIL_REGEX.test(email)) { // Setup email. const mailOptions = { from: process.env.OUTBOX_EMAIL, to: process.env.INBOX_EMAIL, subject: `Message From ${name} - ${email}`, text: message + " | Reply to: " + email, html: `<div>${message}</div>`, }; // Setup service. const transporter = nodemailer.createTransport({ host: "smtp.zoho.com", secure: true, port: 465, authMethod: "LOGIN", auth: { user: process.env.OUTBOX_EMAIL, pass: process.env.OUTBOX_EMAIL_PASSWORD, }, }); // Send email. transporter.sendMail(mailOptions, (err, response) => { if (err) { console.log(err); reject(err); } else { console.log("Email sent", response); resolve(response); } }); } else { reject("Please provide a valid email address"); } } else { reject("All fields are required"); } }); }

Finally, run npm run dev if you haven't already and test the form by accessing http://localhost:3000 from your browser.

Is the reCAPTCHA badge ruining your awesome page design? Just add .grecaptcha-badge { display: none; } to your global style file to hide it. In this case, add it to styles/global.css

And there you have it! You've successfully built a fully functional contact form where submissions are protected by Google reCAPTCHA. As usual, you can find the application source code in my GitHub repository.

Thanks for reading! Feel free to contact me if you have any questions.

Share:
Questions or comments?

Was this helpful?

If this content added value to your day, consider fueling my late-night coding sessions! Your support helps me keep creating and sharing more geeky goodness.

Buy me a coffeeBuy me a coffee