Google reCAPTCHA v3 integration for Next.js applications with Server Actions support.
- ✅ Client Component:
RecaptchaWrapperfor automatic token generation - ✅ Server Validation:
validateRecaptchafunction for Server Actions - ✅ TypeScript Support: Full type definitions included
- ✅ Next.js Optimized: Works with App Router and Server Actions
- ✅ Auto Token Refresh: Tokens refresh automatically before expiration
- ✅ Graceful Degradation: Works in development without credentials
- ✅ Configurable Thresholds: Custom score thresholds per form
- ✅ Lazy Loading: Optional lazy loading for better performance
- ✅ Singleton Script Loading: Prevents duplicate script loads across multiple forms
npm install @silverassist/recaptcha
# or
yarn add @silverassist/recaptcha
# or
pnpm add @silverassist/recaptcha- Go to Google reCAPTCHA Admin
- Create a new site with reCAPTCHA v3
- Get your Site Key (public) and Secret Key (private)
# .env.local
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=your_site_key_here
RECAPTCHA_SECRET_KEY=your_secret_key_hereAdd RecaptchaWrapper inside your form:
"use client";
import { RecaptchaWrapper } from "@silverassist/recaptcha";
export function ContactForm() {
return (
<form action={submitForm}>
<RecaptchaWrapper action="contact_form" />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}Validate the token in your Server Action:
"use server";
import { validateRecaptcha, getRecaptchaToken } from "@silverassist/recaptcha/server";
export async function submitForm(formData: FormData) {
// Get and validate reCAPTCHA token
const token = getRecaptchaToken(formData);
const recaptcha = await validateRecaptcha(token, "contact_form");
if (!recaptcha.success) {
return { success: false, message: recaptcha.error };
}
// Process form data...
const email = formData.get("email");
const message = formData.get("message");
// Your form processing logic here
return { success: true };
}RecaptchaWrapper injects a hidden input field containing the reCAPTCHA token. If your form handler creates a custom FormData object, you must ensure the hidden token is included.
"use client";
import { RecaptchaWrapper } from "@silverassist/recaptcha";
import { submitForm } from "./actions"; // Your server action
export function ContactForm() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// ❌ Creating empty FormData - hidden reCAPTCHA input is NOT included!
const formData = new FormData();
formData.set("email", "user@example.com");
formData.set("message", "Hello");
await submitForm(formData);
};
return (
<form onSubmit={handleSubmit}>
<RecaptchaWrapper action="contact_form" />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}"use client";
import { RecaptchaWrapper } from "@silverassist/recaptcha";
import { submitForm } from "./actions"; // Your server action
export function ContactForm() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// ✅ Pass form element - captures ALL inputs including hidden reCAPTCHA token
const formData = new FormData(e.currentTarget);
await submitForm(formData);
};
return (
<form onSubmit={handleSubmit}>
<RecaptchaWrapper action="contact_form" />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}"use client";
import { RecaptchaWrapper } from "@silverassist/recaptcha";
import { submitForm } from "./actions"; // Your server action
export function ContactForm() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// ✅ Start with form element (includes hidden token)
const formData = new FormData(e.currentTarget);
// Then add/override specific fields
formData.set("customField", "customValue");
formData.set("timestamp", Date.now().toString());
await submitForm(formData);
};
return (
<form onSubmit={handleSubmit}>
<RecaptchaWrapper action="contact_form" />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}The lazy prop enables lazy loading of the reCAPTCHA script, which defers loading until the form becomes visible in the viewport. This significantly improves initial page load performance.
Note: These metrics are approximate values measured on production websites using Google reCAPTCHA v3. Actual performance improvements will vary based on network conditions, device capabilities, and page complexity.
| Metric | Without Lazy Loading | With Lazy Loading | Improvement |
|---|---|---|---|
| Initial JS | 320KB+ | 0 KB (until visible) | -320KB |
| TBT (Total Blocking Time) | ~470ms | ~0ms (deferred) | -470ms |
| TTI (Time to Interactive) | +2-3s | Minimal impact | -2-3s |
Enable lazy loading by adding the lazy prop:
"use client";
import { RecaptchaWrapper } from "@silverassist/recaptcha";
export function ContactForm() {
return (
<form action={submitForm}>
{/* Script loads only when form is near viewport */}
<RecaptchaWrapper action="contact_form" lazy />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}Control when the script loads with the lazyRootMargin prop (default: "200px"):
// Load script earlier (400px before entering viewport)
<RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
// Load script later (load only when fully visible)
<RecaptchaWrapper action="contact_form" lazy lazyRootMargin="0px" />
// Load with negative margin (load only after scrolling past)
<RecaptchaWrapper action="contact_form" lazy lazyRootMargin="-100px" />// Hero form (above the fold) - load immediately
<RecaptchaWrapper action="hero_signup" />
// Footer form (below the fold) - lazy load
<RecaptchaWrapper action="footer_contact" lazy />The package automatically uses singleton script loading, so the script is only loaded once even with multiple forms:
export function MultiFormPage() {
return (
<>
{/* First form triggers script load */}
<RecaptchaWrapper action="newsletter" lazy />
{/* Second form reuses the same script */}
<RecaptchaWrapper action="contact" lazy />
{/* Third form also reuses the script */}
<RecaptchaWrapper action="feedback" lazy />
</>
);
}// Form near top - smaller margin for faster load
<RecaptchaWrapper action="signup" lazy lazyRootMargin="100px" />
// Form far down page - larger margin to load in advance
<RecaptchaWrapper action="newsletter" lazy lazyRootMargin="500px" />Client component that loads reCAPTCHA and generates tokens.
<RecaptchaWrapper
action="contact_form" // Required: action name for analytics
inputName="recaptchaToken" // Optional: hidden input name (default: "recaptchaToken")
inputId="recaptcha-token" // Optional: hidden input id
siteKey="..." // Optional: override env variable
refreshInterval={90000} // Optional: token refresh interval in ms (default: 90000)
onTokenGenerated={(token) => {}} // Optional: callback when token is generated
onError={(error) => {}} // Optional: callback on error
lazy={false} // Optional: enable lazy loading (default: false)
lazyRootMargin="200px" // Optional: IntersectionObserver rootMargin (default: "200px")
/>| Prop | Type | Default | Description |
|---|---|---|---|
action |
string |
Required | Action name for reCAPTCHA analytics (e.g., "contact_form", "signup") |
inputName |
string |
"recaptchaToken" |
Name attribute for the hidden input field |
inputId |
string |
"recaptcha-token" |
ID attribute for the hidden input field |
siteKey |
string |
process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY |
Override the site key from environment variable |
refreshInterval |
number |
90000 |
Token refresh interval in milliseconds (90 seconds) |
onTokenGenerated |
(token: string) => void |
undefined |
Callback invoked when a new token is generated |
onError |
(error: Error) => void |
undefined |
Callback invoked when an error occurs |
lazy |
boolean |
false |
Enable lazy loading to defer script until form is visible |
lazyRootMargin |
string |
"200px" |
IntersectionObserver rootMargin (used when lazy is true) |
Server-side token validation function.
const result = await validateRecaptcha(
token, // Token from form
"contact_form", // Expected action (optional)
{
scoreThreshold: 0.5, // Minimum score (default: 0.5)
secretKey: "...", // Override env variable
debug: true, // Enable debug logging
}
);
// Result type:
// {
// success: boolean,
// score: number,
// error?: string,
// skipped?: boolean,
// rawResponse?: RecaptchaVerifyResponse
// }Check if reCAPTCHA is configured.
import { isRecaptchaEnabled } from "@silverassist/recaptcha/server";
if (isRecaptchaEnabled()) {
// Validate token
} else {
// Skip validation (development)
}Extract token from FormData.
import { getRecaptchaToken } from "@silverassist/recaptcha/server";
const token = getRecaptchaToken(formData);
const token = getRecaptchaToken(formData, "customFieldName");reCAPTCHA v3 returns a score from 0.0 to 1.0:
| Score | Meaning |
|---|---|
| 1.0 | Very likely human |
| 0.7+ | Likely human |
| 0.5 | Default threshold |
| 0.3- | Suspicious |
| 0.0 | Very likely bot |
Adjust threshold based on form sensitivity:
// Standard forms
await validateRecaptcha(token, "contact", { scoreThreshold: 0.5 });
// Sensitive forms (payments, account creation)
await validateRecaptcha(token, "payment", { scoreThreshold: 0.7 });
// Low-risk forms (newsletter signup)
await validateRecaptcha(token, "newsletter", { scoreThreshold: 0.3 });You can import from specific subpaths for better tree-shaking:
// Main exports (client + server + types)
import { RecaptchaWrapper, validateRecaptcha } from "@silverassist/recaptcha";
// Client only
import { RecaptchaWrapper } from "@silverassist/recaptcha/client";
// Server only
import { validateRecaptcha, getRecaptchaToken, isRecaptchaEnabled } from "@silverassist/recaptcha/server";
// Types only
import type { RecaptchaValidationResult, RecaptchaWrapperProps } from "@silverassist/recaptcha/types";
// Constants only
import { DEFAULT_SCORE_THRESHOLD, RECAPTCHA_CONFIG } from "@silverassist/recaptcha/constants";In development, when RECAPTCHA_SECRET_KEY is not set, validation is skipped and forms work normally. This allows testing without reCAPTCHA credentials.
const result = await validateRecaptcha(token, "test");
// Returns: { success: true, score: 1, skipped: true }Full TypeScript support with exported types:
import type {
RecaptchaWrapperProps,
RecaptchaValidationResult,
RecaptchaVerifyResponse,
RecaptchaConfig,
RecaptchaValidationOptions,
} from "@silverassist/recaptcha";