Integrating reCAPTCHA v3 with form in a Next.js application
8 min read
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 tostyles/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.