Hiding a form behind a client-side condition doesn't make its API route private. Unless the route enforces its own checks, another client can call it directly.
For a public contact form, you may not want to require an account. You still want the server to validate the request before it triggers an email or database write.
This example uses a Next.js App Router route handler and self-hosted FCaptcha. The browser collects a token. The server verifies it before accepting the submission.
Disclosure: I build WebDecoy and maintain FCaptcha, the free open-source project used here.
Run it locally
Complete example and README. Use the docs/angular-nextjs-tutorials branch shown below; these instructions work before it is merged into main.
The example pins Next.js 16.3.6 and uses FCaptcha 1.42. It was built and tested with Node 26.5.
git clone --branch docs/angular-nextjs-tutorials https://github.com/WebDecoy/FCaptcha.git
cd FCaptcha
npm --prefix server-node install --ignore-scripts --package-lock=false
cd examples/framework-forms
npm ci
npm --prefix nextjs ci
npm run next
Open http://127.0.0.1:3000. The launcher starts both Next.js and FCaptcha (port 8788), creates separate temporary signing and verification secrets, and passes server configuration through the environment. Use 127.0.0.1 consistently because hostname validation is exact.
The server-node install command works around an existing upstream lockfile mismatch. The example dependencies have their own lockfiles.
Separate the browser and server responsibilities
The source layout is small:
nextjs/app/page.js client form
nextjs/app/api/contact/route.js server entry point
shared/browser.js widget loading and submission
shared/contact.mjs validation and verification
page.js is a client component. It imports only the browser helper. A useRef guard prevents two submissions before React has rendered the pending state; useState supplies the visible status and disabled button.
On each attempt, the helper loads the widget, requests a token for action: 'contact', then sends { message, token } to /api/contact. It never receives the verification secret.
const result = await window.FCaptcha.execute('framework-contact', {
action: 'contact', lang: 'en'
});
if (!result.success || !result.token) {
throw new Error('Verification did not pass. Please try again.');
}
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, token: result.token }),
signal: AbortSignal.timeout(8000)
});
This excerpt runs after the loader configures FCaptcha. The full helper checks the HTTP response and accepted field before displaying success.
Keep verification in the route handler
Here is the complete route entry point:
import { contactHandler } from '../../../../shared/contact.mjs';
export const runtime = 'nodejs';
export async function POST(request) {
try {
return await contactHandler({ origin: process.env.APP_ORIGIN,
captchaOrigin: process.env.FCAPTCHA_ORIGIN, verifySecret: process.env.FCAPTCHA_VERIFY_SECRET })(request);
} catch { return Response.json({ error: 'server_configuration_error' }, { status: 503 }); }
}
The shared handler uses Node APIs, so the runtime is explicit. The verification secret is read only on the server. Do not rename it to a NEXT_PUBLIC_* variable or pass it to the page as a prop.
Before contacting FCaptcha, the handler requires the expected Origin, JSON content type, a body no larger than 16 KiB, a nonempty message of at most 2,000 characters and a token. It then performs this server-to-server request:
const verification = await fetch(new URL('/siteverify', captchaOrigin), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret: verifySecret, response: body.token }),
signal: AbortSignal.timeout(5000),
cache: 'no-store'
});
if (!verification.ok) throw new Error('Verification unavailable');
const result = await verification.json();
if (result?.success !== true ||
result.hostname !== hostname ||
result.action !== 'contact') {
return Response.json({ error: 'captcha_rejected' }, { status: 403 });
}
// Only now perform the protected action.
A token for another hostname or another action is rejected even if its signature is valid. A successful verification consumes the token, so replay is rejected too.
Treat verification failure as failure
The handler catches network errors and malformed verifier responses and returns 503. A rejected token returns 403; invalid input returns 400. The protected action never runs in those branches.
Don't turn a timeout into success: true to keep the form moving. Show an error and let the visitor retry with a new token. The client keeps the message in the textarea and clears its pending state in finally.
You can check that a direct request cannot skip verification:
curl -i http://127.0.0.1:3000/api/contact \
-H 'Origin: http://127.0.0.1:3000' \
-H 'Content-Type: application/json' \
--data '{"message":"hello","token":"forged"}'
The running example returns 403 with captcha_rejected.
What this example tests
npm test runs 17 integration checks across the Express adapter and the Next.js route handler against the real FCaptcha verification endpoint. They cover valid tokens, replay, forged and expired tokens, hostname/action mismatches, input limits, origin checks and verification outages. Both framework production builds also passed.
The tests sign fixtures with a temporary test key. They test the integration contract—not the browser's ability to distinguish humans from bots. No human pass-rate claim follows from these tests.
Before you deploy
The launcher is deliberately local: it creates fresh, separate signing and verification secrets for every run and binds FCaptcha to loopback. The browser helper also uses a hardcoded loopback URL. Replace those with your own HTTPS origins, public site key and stable server-side secrets for deployment.
Keep rate limits, request-size limits and authentication/authorization where needed. An Origin check helps with unwanted browser requests, but a script can forge that header. CAPTCHA is another check, not a replacement for authorization or a complete spam defense.
When you add email or database writes, handle duplicate business actions separately. Verification consumes the token; if the later action fails, the retry needs a new token. Multiple FCaptcha replicas also need shared state for replay protection. Give legitimate visitors a recovery path if verification fails.
This demo only returns { accepted: true, demoOnly: true }. It does not store a message or send an email.
FCaptcha is free and open source. If you try the example, useful feedback is a reproducible integration issue—including framework version and the failing step—with tokens and secrets removed.
Framework reference: Next.js Route Handlers.
Top comments (0)