Fight Spam with NextJS+Google CAPTCHA

I'm working on a project (mission?) to release 10 web applications to the internet in one year. My first step on this journey has been to stand up a simple personal landing page, a place to link to my blog and all of the other projects I'm going to be developing over time.

I also wanted a simple message capture form for folks to reach out and send me requests for freelancing and consulting work. What I do not want is for that form to get spammed by bots, trolls and the other degenerates that plague... most of the internet. So, a CAPTCHA widget was a necessity. In this short blog I'll go through how I implemented CAPTCHA in my contact form.

I used this blog as a reference for setting all of this up.

Prerequisites

I was fairly surprised at how simple this process actually is. You really only need a site key and a server key from Google's reCAPTCHA dashboard, one NPM library and a couple of extra lines of code. I'm also using the T3 Stack for my personal landing page, so check out the docs for a bit more details about the implementation.

First, install the required types and library using NPM

npm install react-google-recaptcha
npm install --save-dev @types/react-google-recaptcha

The first install line will install the react-google-recaptcha library, while the second will install the necessary types for the library to get Typescript to work nicely.

Next, you'll need to head to the reCAPTCHA dashboard, sign in with your Google account and create a new site for your reCAPTCHA credentials.

I used the reCAPTCHA v2 Challenge option and used my personal landing site domain. You'll then get two different keys: a site key and a secret key:

Your site key goes in the frontend and will be used to verify that the site where you're placing the widget is a domain registered in reCAPTCHA's backend. The secret key is to be used on your backend (thus you should never reveal it to anyone) and it will be used to verify a reCAPTCHA request.

The Contact Page

Next up, let's take a look at the contact page! I'll post all of the code below and walk through it

import React, { useState, useRef, RefObject} from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
export default function ContactSection() {
	const [email, setEmail] = useState<String>('');
	const [sentSuccess, setSentSuccess] = useState<Boolean>(false)
	const [message, setMessage] = useState<String>('');
	const [invalidEmail, setInvalidEmail] = useState<Boolean>(false);
	const [invalidMessage, setInvalidMessage] = useState<Boolean>(false);
	const [captcha, setCaptcha] = useState<String>('');
	const recaptcha: RefObject<ReCAPTCHA> = useRef(null);
	function validateemail(email: String) {
		if (!email.match(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/)) {
			console.log('invalid email');
			setInvalidEmail(true);
			return false;
		} else {
			console.log('valid email');
			setInvalidEmail(false);
			return true;
		}
	}
	function validatemessage(message: String) {
		if (message.length < 5 || message.length > 1000) {
			console.log('invalid message');
			setInvalidMessage(true);
			return false;
		} else {
			console.log('valid email');
			setInvalidMessage(false);
			return true;
		}
	}
	function emailSetter(email: String) {
		let valid = validateemail(email);
		if (valid) {
			setEmail(email);
		} else {
			setEmail('');
		}
	}
	function messageSetter(message: String) {
		let valid = validatemessage(message);
		if (valid) {
			setMessage(message);
		}
	}
	function captchaChange(value: string | null) {
		if(value) {
			setCaptcha(value);
		}
	}
	async function handleClick() {
		if (!validateemail(email) || !validatemessage(message)) {
			alert('Please enter a valid email address and message');
			return;
		}
		if (!captcha) {
			alert('Please complete the captcha');
			return;
		}
		const res = await fetch('/api', {
			method: 'POST',
			headers: {
				'Content-Type': 'application/json',
			},
			body: JSON.stringify({ email, message, captcha}),
		});
		if (res.ok) {
			setSentSuccess(true);
			recaptcha.current?.reset();
		} else {
			alert('There was an error sending your message. Please try again later.');
			console.log(res);
		}
	}
	return(<div id="contact" className="text-center w-1/2 mx-auto">
			<h1 className="text-4xl font-bold mb-10">Contact Me</h1>
			<h2 className="text-lg font-semibold">I love to work short and long-term consulting and software freelancing contracts. If there is anything you need built, on the web or otherwise, shoot me a message via the contact form below and I will email you as soon as possible to start the conversation.</h2>
			{sentSuccess ? <div className="py-2 mt-10 rounded-md bg-[#ecffeb] border-2 border-green-500 w-1/2 mx-auto" ><p className="text-green-500 font-bold">Message sent successfully!</p></div> : null}

			<div className="contactForm flex flex-col py-10" >
			<label htmlFor="email" hidden={sentSuccess ? true : false}>EMail Address</label>
			<input  hidden={sentSuccess ? true : false} id="email" name="email" className="!text-black" onChange={(e : React.ChangeEvent<HTMLInputElement>)=> emailSetter(e.target.value)} type="email"/>
			{invalidEmail  ? <p className="text-red-500">Please enter a valid email address</p> : null}
			<label hidden={sentSuccess ? true : false } htmlFor="message">How can I help you?</label>
			<textarea
			rows={4} 
			name="message" 
			id="message" 
			hidden={sentSuccess ? true : false}
			className="!text-black" onChange={(e : React.ChangeEvent<HTMLTextAreaElement>) => setMessage(e.currentTarget.value)}/>
			<ReCAPTCHA
			size="normal"
			sitekey="<site-key>"
			onChange={captchaChange}
			ref={recaptcha}
			className="mx-auto mt-10"/>
			<button className="bg-white text-[#2a2a2a] border-2 border-[#2a2a2a] font-bold mt-5 rounded-md 
			hover:bg-[#2a2a2a] hover:text-white mx-auto w-1/4 hover:border-white 
			hover:border-2" hidden={sentSuccess ? true : false} onClick={handleClick}>Submit</button>
			</div>
			</div>
	);
}

I'm using a couple pieces of state and some validation here that's notable. My form is pretty simple: an email field, a message field and a submit button. Paired with the token generated by reCAPTCHA, this data is sent off to a backend API endpoint that looks like this:

import { PrismaClient } from '@prisma/client';
import { NextApiRequest, NextApiResponse } from 'next';
const prisma = new PrismaClient();
import axios from 'axios'
export default async (req: NextApiRequest, res: NextApiResponse) => {
	let { email, message } = req.body;
	let captchakey = process.env.CAPTCHA_SECRET_KEY;
	console.log("Captcha key: ", captchakey)
	const response = await axios.post(
		`https://www.google.com/recaptcha/api/siteverify?secret=${captchakey}&response=${req.body.captcha}`
	);
	if (!response.data.success) {
		console.log("Response data from failed captcha request: ", response.data)
		res.status(403).json({ message: 'Captcha failed' });
		return;
	}
	let newMessage = await prisma.emailMsg.create({
		data: {
			email: email,
			message: message,
		},
	});
	console.log('Created new message: ', newMessage);
	res.status(200).json({ message: 'Committed' });
	return;
};

Front End

Let's start with the front-end code. We have a few state variables to manage:
- EMail
- Message
- reCAPTCHA token
- sentSuccess, which is a boolean to see if the user has already sent a successful message

The EMail and Message state fields also have validation functions. I don't want folks sending me massive messages with their entire life story (or the script to the Bee Movie) and I want the email to be legitimate so that I can answer them. That's what the validateemail() and validatemessage() functions do.

Finally, I implement the reCAPTCHA widget, which just takes in a size parameter, my site key (I've redacted this, but it's visible in the front end anyways and isn't super useful if you don't have the secret key) and a callback for a function that will store the token that reCAPTCHA generates when you solve the CAPTCHA. That callback function, captchaChange(), just updates the CAPTCHA state. The rest of the code in the front end is functionally just styling.

What you get from all of this in the front end is this:

A simple form!

The only real quirk is creating the ref to the reCAPTCHA widget which is passed in the creation of the widget component. This ref is used to refresh the state of the widget after you've made your API call.

Back End

The back end code is simple. You make one request to the Google reCAPTCHA backend using your secret key (which you store in .env in my case) and the token that's generated by the reCAPTCHA widget and passed to the back end in the call to the API endpoint in handleClick(). If the request is successful, that means the reCAPTCHA was solved successfully. if not, you can return a 403.

If the CAPTCHA is validated, my backend will use Prisma to make a request to a Vercel PostGreSQL database and store the message and email for me to read later.

Conclusion

I was fairly surprised just how easy it was to implement reCAPTCHA in my site. It's free, easy to use and implement and shouldn't require much hassle going forward.

By the way, my personal landing site is live now! If you're looking for a web developer, Rust developer or software engineering freelance or consulting work, shoot me a message in the contact form!