natbox.io

CaptchaBox / Docs / Next.js

Add a captcha to Next.js

Last updated 6 November 2026 · By Mohammad AlSaraf

About 10 minutes. Next.js App Router with React 19 (Next.js 15); TypeScript.

You will add a small client component that shows the widget, a server-only function that checks the token, and use both in a form with a Server Action.

1. Get your keys

Sign in to the CaptchaBox client area (a free account is enough) and add your site with its domain. You get a site key for the page and a secret for your server. Keep the secret out of your HTML and out of git: put it in an environment variable.

Put them in .env.local. Only the site key gets the NEXT_PUBLIC_ prefix, so the secret never reaches the browser:

# Test keys that always pass, on any domain. Replace them with your own from my.natbox.io/captchabox
NEXT_PUBLIC_CAPTCHABOX_SITE_KEY=cb_test_pass
CAPTCHABOX_SECRET=cbs_test_pass

2. The widget component

components/CaptchaBox.tsx loads the script once and renders the widget. Change resetKey to get a fresh token, for example after each submit:

'use client';

import { useEffect, useRef } from 'react';

declare global {
  interface Window {
    captchabox?: {
      render: (el: HTMLElement, opts: Record<string, unknown>) => number;
      reset: (el?: HTMLElement) => void;
    };
  }
}

const SRC = 'https://captcha.natbox.io/v1/widget.js';

// Loads the widget once and renders it into this element. It adds a hidden
// "captchabox-response" field to the surrounding <form>.
// Change `resetKey` (e.g. after each submit) to get a fresh token.
export default function CaptchaBox({ siteKey, theme = 'auto', resetKey }: { siteKey: string; theme?: 'auto' | 'light' | 'dark'; resetKey?: unknown }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const render = () => window.captchabox?.render(el, { sitekey: siteKey, theme });
    if (window.captchabox) return void render();
    let s = document.querySelector<HTMLScriptElement>(`script[src="${SRC}"]`);
    if (!s) {
      s = document.createElement('script');
      s.src = SRC;
      s.async = true;
      document.head.appendChild(s);
    }
    s.addEventListener('load', render, { once: true });
  }, [siteKey, theme]);

  useEffect(() => {
    if (resetKey !== undefined && ref.current && window.captchabox) window.captchabox.reset(ref.current);
  }, [resetKey]);

  return <div ref={ref} />;
}

3. The server check

lib/captchabox.ts:

// Server-side check. Import only from server code (Server Actions, Route Handlers).
// Each token works once, for 5 minutes.
export async function verifyCaptchaBox(token: unknown): Promise<boolean> {
  if (typeof token !== 'string' || token === '') return false;
  try {
    const res = await fetch('https://captcha.natbox.io/v1/siteverify', {
      method: 'POST',
      body: new URLSearchParams({ secret: process.env.CAPTCHABOX_SECRET ?? '', response: token }),
      cache: 'no-store',
      signal: AbortSignal.timeout(10_000),
    });
    const data = await res.json();
    return data.success === true;
  } catch {
    return false;
  }
}

4. Use them in a form

The Server Action, app/contact/actions.ts:

'use server';

import { verifyCaptchaBox } from '@/lib/captchabox';

export type State = { ok: boolean; message: string } | null;

export async function sendMessage(_prev: State, formData: FormData): Promise<State> {
  if (!(await verifyCaptchaBox(formData.get('captchabox-response')))) {
    return { ok: false, message: 'The captcha check failed. Please try again.' };
  }
  // The visitor passed: send the email, save the message, etc.
  return { ok: true, message: 'Thanks, your message was sent.' };
}

The form, app/contact/ContactForm.tsx:

'use client';

import { useActionState } from 'react';
import CaptchaBox from '@/components/CaptchaBox';
import { sendMessage } from './actions';

export default function ContactForm() {
  const [state, action, pending] = useActionState(sendMessage, null);
  return (
    <form action={action}>
      {state && <p id="result">{state.message}</p>}
      <input name="email" type="email" placeholder="Your email" required />
      {/* a token works once, so get a new one after every send */}
      <CaptchaBox siteKey={process.env.NEXT_PUBLIC_CAPTCHABOX_SITE_KEY!} resetKey={state} />
      <button disabled={pending}>Send</button>
    </form>
  );
}

Render <ContactForm /> on any page. If the visitor presses Send before the check is done, the widget holds the submit and sends it as soon as the token is ready.

With a Route Handler instead

// app/api/contact/route.ts
import { verifyCaptchaBox } from '@/lib/captchabox';

export async function POST(req: Request) {
  const { email, token } = await req.json();
  if (!(await verifyCaptchaBox(token))) return Response.json({ error: 'captcha' }, { status: 400 });
  // the visitor passed
  return Response.json({ ok: true });
}

In the browser, get the token with await window.captchabox!.execute() and send it with your JSON.

Trying it out? The test keys cb_test_pass (site key) and cbs_test_pass (secret) always pass, on any domain including localhost. Swap in your own keys from the client area before going live.

Other platforms: all CaptchaBox docs. Questions? Open a ticket, we reply within 24 hours.