reCAPTCHA v3
Next Starter integrates Google reCAPTCHA v3 to protect sign-in, registration, and contact forms from bots, with server-side score verification and no user-facing challenges.
Overview
Next Starter uses Google reCAPTCHA v3 to protect forms from bots. Unlike v2, reCAPTCHA v3 runs entirely in the background: users never see a challenge. It returns a score (0.0 to 1.0) indicating how likely the interaction is to be human. The server-side action in app/actions/recaptcha.ts verifies the token and rejects low-confidence requests in production.
The integration uses the next-recaptcha-v3 package, which provides a React context and hook built for Next.js.
Environment Variables
NEXT_PUBLIC_RECAPTCHA_SITE_KEY="6Le..."
RECAPTCHA_SECRET_KEY="6Le..."Both variables are optional. When RECAPTCHA_SECRET_KEY is absent, the server-side verification function returns true in development and false in production (forcing requests through in local environments without breaking the flow). NEXT_PUBLIC_RECAPTCHA_SITE_KEY controls whether the reCAPTCHA script loads on the client. Without it, token generation will fail.
Obtain both keys from the Google reCAPTCHA admin console. Register your site with the reCAPTCHA v3 type and add your production domain.
Server-Side Verification
The verification logic lives in app/actions/recaptcha.ts as a Server Action:
"use server";
import { after } from "next/server";
import { logger } from "@/lib/logger";
export async function validateRecaptcha(token: string): Promise<boolean> {
try {
if (!process.env.RECAPTCHA_SECRET_KEY) {
after(() => {
logger.error(
{ event: "recaptcha_missing_secret" },
"RECAPTCHA_SECRET_KEY not configured",
);
});
return process.env.NODE_ENV !== "production";
}
const response = await fetch(
"https://www.google.com/recaptcha/api/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
},
);
const result = await response.json();
if (!result.success) {
if (process.env.NODE_ENV === "production") {
return false;
}
return true;
}
if (result.score !== undefined) {
const scoreThreshold = 0.5;
if (result.score < scoreThreshold) {
if (process.env.NODE_ENV === "production") {
return false;
}
return true;
}
}
return true;
} catch (error) {
after(() => {
logger.error(
{ event: "recaptcha_verification_error", err: error },
"reCAPTCHA verification failed",
);
});
return process.env.NODE_ENV !== "production";
}
}Score Threshold
The default threshold is 0.5. Scores above this are treated as human; scores below are treated as bot traffic and rejected in production. You can adjust this value to be more or less strict:
0.7or higher: more aggressive bot filtering, may occasionally block legitimate users0.3or lower: more permissive, useful if your users tend to interact in unusual patterns
Logging
Errors are logged using after() from next/server, which runs the logging callback after the response is sent. This avoids adding latency to the user-facing request in the error path.
Client-Side Token Generation
Wrap any form component in ReCaptchaProvider, then use the useReCaptcha hook to execute the challenge before submission.
Provider Setup
import { ReCaptchaProvider } from "next-recaptcha-v3";
export function SignInForm() {
return (
<ReCaptchaProvider reCaptchaKey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!}>
<SignInFormInner />
</ReCaptchaProvider>
);
}The provider loads the reCAPTCHA script and makes the site key available to all child components. Wrap only the form component rather than a top-level layout to avoid loading the script on every page.
Executing the Challenge
Inside the wrapped component, call executeRecaptcha with an action name before submitting the form:
"use client";
import { useReCaptcha } from "next-recaptcha-v3";
import { validateRecaptcha } from "@/app/actions/recaptcha";
function SignInFormInner() {
const { executeRecaptcha } = useReCaptcha();
const handleSubmit = async (values: FormValues) => {
const token = await executeRecaptcha("login");
if (!token) {
// token generation failed
return;
}
const isHuman = await validateRecaptcha(token);
if (!isHuman) {
// reject the submission
return;
}
// proceed with the actual form action
await signIn.email({ email: values.email, password: values.password });
};
}The action name passed to executeRecaptcha (e.g., "login", "register", "contact_form") appears in the reCAPTCHA admin console and helps you identify which forms are being targeted. Use a descriptive, lowercase string without spaces.
Protecting a Form
The pattern used across all protected forms in Next Starter is:
- Wrap the form in
ReCaptchaProvider(exported from the parent component) - Call
executeRecaptcha(actionName)at the start of the submit handler - Pass the token to
validateRecaptchabefore running any business logic - Return early with a user-facing error if verification fails
The sign-in, register, forgot password, and contact forms all follow this pattern.
Adding reCAPTCHA to a New Form
The contact form shows the recommended pattern. The token is appended to the FormData payload and verified inside the server action, which keeps verification server-side and avoids an extra round-trip:
"use client";
import { ReCaptchaProvider, useReCaptcha } from "next-recaptcha-v3";
import { submitContactForm } from "@/app/actions/contact";
function ContactFormInner() {
const { executeRecaptcha } = useReCaptcha();
const [error, setError] = useState("");
const handleSubmit = async (values: ContactFormValues) => {
const token = await executeRecaptcha("contact_form");
if (!token) {
setError("reCAPTCHA verification failed. Please refresh and try again.");
return;
}
const formData = new FormData();
formData.append("name", values.name);
formData.append("email", values.email);
formData.append("message", values.message);
formData.append("recaptcha_token", token);
const response = await submitContactForm(formData);
if (!response.success) {
setError(response.error || "Failed to send message");
}
};
return <form onSubmit={form.handleSubmit(handleSubmit)}>{/* ... */}</form>;
}
export function ContactForm() {
return (
<ReCaptchaProvider reCaptchaKey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!}>
<ContactFormInner />
</ReCaptchaProvider>
);
}The server action (app/actions/contact.ts) reads recaptcha_token from the FormData and calls validateRecaptcha internally before processing the submission.
CSP Configuration
The reCAPTCHA widget loads scripts and renders an iframe from Google's domains. The Content Security Policy in next.config.ts already includes the necessary allowances:
`script-src 'self' 'unsafe-inline' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/`,
`connect-src 'self' https://www.google.com/recaptcha/ ...`,
`frame-src 'self' https://www.google.com/recaptcha/`,No CSP changes are needed when adding reCAPTCHA to a new form on the same site.
Testing in Development
When RECAPTCHA_SECRET_KEY is not set:
- The
validateRecaptchafunction returnstrueautomatically in development - Forms work normally without any reCAPTCHA interaction
- An error is logged via Pino when
validateRecaptchais called without a secret key configured
Note: NEXT_PUBLIC_RECAPTCHA_SITE_KEY controls whether the reCAPTCHA script loads on the client. If it is absent, token generation silently fails, but the server-side bypass still applies when RECAPTCHA_SECRET_KEY is also unset.
To test the full reCAPTCHA flow locally, set both keys in your .env file. Add localhost to your allowed domains in the reCAPTCHA admin console. Google does not accept localhost as an origin automatically.
To verify that bot rejection works correctly, you can temporarily raise the score threshold to 1.1 (which will never be reached) and confirm that the form rejects submissions in production mode.
Security Headers & CSP
How Next Starter configures HTTP security headers, including Content Security Policy, HSTS, X-Frame-Options, Referrer-Policy, and more, inside next.config.ts.
Database Setup
How to configure PostgreSQL with Prisma 7 for Next Starter, including local Docker setup, running migrations, hosted database providers, and connection pooling for serverless deployments.