Stop form spam in Next.js
Updated
This guide adds Bot403 to a contact form in the Next.js App Router. The browser solves a small proof of work. Then a server action checks the token with one call, before it accepts the message. The visitor sees no puzzle, and the script sets no cookie.
The example uses Next.js 16.4 and React 19.3. It was run on 23 October 2026 against a local copy of the verify API. The tests covered a good token, a reused token and an expired token. The tests also covered a filled honeypot, a capped month, an API that does not answer and a second try after a failed one.
Before you start
Sign in. Create a site. Copy its public key (pk_) and its secret key (sk_). Put the secret key in .env.local. In production, also put it in your host's settings:
# .env.local: no NEXT_PUBLIC_ prefix, so the key stays on the server
BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY1. The script
Load the script once, in the root layout. The script protects every form on every page. This includes forms that React renders later.
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<script src="https://bot403.com/v1/bot403.js" data-bot403-key="pk_live_YOUR_PUBLIC_KEY" async />
</head>
<body>{children}</body>
</html>
);
}2. The check
One server-only function sends the form fields to Bot403. It returns true when the token is good. It also returns this value when the site is over the plan limit in a capped month (reason quota). It does the same when Bot403 does not answer in 5 seconds.
// Runs on the server only. The secret key never reaches the browser
export async function checkBot403(form: FormData): Promise<boolean> {
// 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field
const body = new URLSearchParams();
for (const [name, value] of form) if (typeof value === "string") body.append(name, value);
try {
const res = await fetch("https://bot403.com/api/v1/verify", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.BOT403_SECRET_KEY}` },
body,
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
const result = await res.json();
// 2. Refuse when ok is false, except for reason "quota"
return result.ok || result.reason === "quota";
} catch (err) {
console.error("bot403 verify failed, letting the form through", err);
return true;
}
}The function sends the fields as application/x-www-form-urlencoded. If you pass the FormData object as the body, fetch sends multipart/form-data. The verify API does not read this format.
3. The form and the server action
"use client";
import { useActionState } from "react";
import { sendMessage } from "./actions";
export default function ContactPage() {
const [error, formAction, pending] = useActionState(sendMessage, "");
return (
<form action={formAction}>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" required /></label>
<button type="submit" disabled={pending}>Send</button>
{error && <p role="alert">{error}</p>}
</form>
);
}"use server";
import { redirect } from "next/navigation";
import { checkBot403 } from "@/lib/bot403";
export async function sendMessage(_prev: string, form: FormData): Promise<string> {
if (!(await checkBot403(form))) return "Try again";
// A real visitor: send the email or store the message here
// redirect() throws on purpose, so call it outside any try/catch
redirect("/thanks");
}The script adds the hidden _bot403 field before React reads the form. As a result, the action gets the token in its FormData. React also adds its own $ACTION_ fields, and Bot403 ignores them. If the action refuses the form, React clears it. When the visitor sends the form again, the form gets a new token.
The trap: server action or route handler, and redirect()
A server action and a route handler both work. Use a server action for a form that React renders, as in step 3. Use a route handler for a plain HTML form that posts to a URL, or a form on another site:
import { checkBot403 } from "@/lib/bot403";
// For a plain <form method="POST" action="/api/contact">
export async function POST(request: Request) {
if (!(await checkBot403(await request.formData()))) {
return new Response("Try again", { status: 400 });
}
return Response.redirect(new URL("/thanks", request.url), 303);
}The trap is in the server action. To do its work, redirect() throws a special error. If this call is inside the try block around the verify call, your catch block catches the error. As a result, the redirect does not occur, and the visitor stays on the form:
// Wrong: redirect() throws, and the catch block swallows it
try {
if (await checkBot403(form)) redirect("/thanks");
} catch (err) {
return "Try again";
}Keep the network call and its try/catch inside checkBot403. Call redirect() after this function, as in actions.ts. Also, never give the key a NEXT_PUBLIC_ prefix. Next.js copies the variables with this prefix into the browser bundle.
Test it
- Run
npm run dev. Openhttp://localhost:3000/contact. Send the form. The browser goes to/thanks, and the site in your dashboard shows one check. - Send a POST without a token to the route handler. The route handler refuses the form, and the dashboard lists the failure with the reason
invalid.
# The route handler with a form that has no token
curl -i -d "[email protected]&message=hi" http://localhost:3000/api/contact
# HTTP/1.1 400 Bad Request
# Try againIf you get a failure that you do not expect, read the troubleshooting page. It lists every reason and its fix.