Skip to content
Antisy

Protect a Next.js form from bots, without a puzzle

For bot protection on a Next.js form, put the tag in the layout, let a POST form send the hidden antisy-token field to a route of your application, and verify it on the server with POST /v1/siteverify: the response is allow or deny. A function without a dependency, a secret that stays on the server.

As of .

What you need

  • A site declared in the Antisy dashboard (in French), with the host names of your pages.
  • The site key (32 characters): it is public and goes in the page; the verification secret (64 characters) stays on your server and is shown only once, when it is created.
  • Pages served over HTTPS.
  • A Next.js application with the App Router, whose route.ts routes receive the form.
  • Node 22 or later: that is what the function of the documentation requires, which has no dependency. Importing it assumes the allowJs option of tsconfig.json, which applications created with create-next-app turn on.

See: Documentation: keys and test environments

Is it available today?

Access is by waiting list. The API and the dashboard (in French) have been in service since 27 October 2026, hosted at Infomaniak in Geneva; no customer is in production yet.

The examples of this guide follow version 1 of the API, as the documentation describes it. To try them without protecting a real form, the documentation describes test keys; a test key never goes into production.

See: Documentation: test keys · The pilot · Join the waiting list

1. The two values

The site key is public, the secret never leaves the server. Neither of them carries the NEXT_PUBLIC_ prefix: Next.js copies those variables into the code sent to the browser, which is exactly what must be avoided for the secret.

.env.local
# .env.local: never in the repository, never prefixed with NEXT_PUBLIC_.
ANTISY_SITE_KEY=VOTRE_CLE_DE_SITE
ANTISY_SECRET=VOTRE_SECRET_DE_VERIFICATION

2. The tag in the layout

The tag is written by a server component, with defer, and reads the site key from the environment of the server.

app/layout.tsx
// app/layout.tsx
import type { ReactNode } from "react";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="fr">
      <head>
        {/* The site key is public: it goes in the page. The secret, never. */}
        <script src="https://api.antisy.com/v1/i/l.js" data-site={process.env.ANTISY_SITE_KEY} defer />
      </head>
      <body>{children}</body>
    </html>
  );
}

It is the form antisy.com uses for its own forms. Only the pages that carry a protected form need the tag: you can put it in the layout of the segment concerned rather than in the root layout.

3. The form

An ordinary HTML form sent by POST to a route of your application, with no state and no JavaScript on your side:

app/contact/page.tsx
// app/contact/page.tsx: an ordinary HTML form, with no state and no JavaScript on your side.
export default function ContactPage() {
  return (
    <form method="post" action="/api/contact">
      <label>
        Nom <input name="name" autoComplete="name" />
      </label>
      <label>
        E-mail <input name="email" type="email" autoComplete="email" />
      </label>
      <label>
        Message <textarea name="message" />
      </label>
      <button>Envoyer</button>
    </form>
  );
}

On submission, the script holds the form while it obtains a token, creates the hidden antisy-token field, fills it in and sends the form again (6 seconds at most). For a submission by fetch or a single-page application, the page on the script describes antisy.token(): this guide does not cover them. It has not been tried with a Server Action.

See: Documentation: which forms the script covers

4. The verification in a Next.js route

Save the Node function of the documentation in lib/antisy.mjs, as it is: it reads the secret from process.env.ANTISY_SECRET and returns allow or deny.

lib/antisy.mjs
// antisy.mjs: Node 22 or later, no dependency.
// Returns "allow" or "deny".
// Antisy does not answer (network, deadline, 5xx): "allow". Everything else that is uncertain: "deny".

// The 429 is a decision that is yours. "allow" by default, as for an outage;
// for a sensitive form, set "deny" (see the page on verification).
const ON_RATE_LIMIT = "allow";

export async function antisyVerdict(token) {
  // 1. A token that is there must have the shape of a token: 1 to 2048 printable ASCII characters.
  //    Otherwise, Antisy is not called and nothing is let through.
  //    An empty or missing field (script blocked) is sent as an empty string: Antisy reads it as a missing token.
  if (token && !/^[\x20-\x7e]{1,2048}$/.test(token)) {
    console.warn("Jeton mal formé, envoi non vérifié");
    return "deny";
  }

  let response;
  try {
    response = await fetch("https://api.antisy.com/v1/siteverify", {
      method: "POST",
      // fetch follows redirects by default: a 3xx would replay the body, secret included, elsewhere.
      // Here it is an error of your call (refused below).
      redirect: "manual",
      // A URLSearchParams body is sent as application/x-www-form-urlencoded.
      body: new URLSearchParams({
        secret: process.env.ANTISY_SECRET, // your secret, never in the page
        response: token ?? "",             // the antisy-token field, as received
      }),
      signal: AbortSignal.timeout(3000),   // beyond 3 s, Antisy does not answer
    });
  } catch (error) {
    console.warn("Antisy injoignable, envoi laissé passer :", error.name);
    return "allow";
  }
  if (response.status >= 500) {
    console.warn("Antisy a répondu " + response.status + ", envoi laissé passer");
    return "allow";
  }
  if (response.status === 429) {
    console.warn("Antisy a répondu 429, décision : " + ON_RATE_LIMIT);
    return ON_RATE_LIMIT;
  }
  if (!response.ok) {
    // 400, 401, 413, 415...: the fault is in your call. To be fixed, never a pass.
    console.error("Antisy a refusé la requête (" + response.status + "), envoi non vérifié");
    return "deny";
  }
  const result = await response.json().catch(() => null);
  if (typeof result?.success !== "boolean") {
    console.error("Réponse d'Antisy illisible, envoi non vérifié");
    return "deny";
  }
  // A missing or refused secret is not an HTTP error: the response is a refusal, with a 200.
  const codes = result["error-codes"] ?? [];
  if (codes.includes("missing-input-secret") || codes.includes("invalid-input-secret")) {
    console.error("Antisy refuse le secret : " + codes.join(", "));
  }
  // success is the only acceptance: it is true if and only if verdict is "allow".
  return result.success ? "allow" : "deny";
}

app/api/contact/route.ts
// app/api/contact/route.ts
import { antisyVerdict } from "../../../lib/antisy.mjs";

export async function POST(request: Request) {
  const form = await request.formData();
  const token = form.get("antisy-token"); // a string, a file or nothing

  // Missing field (script blocked): empty string, Antisy decides. A file is not a token: refusal.
  const verdict = token === null || typeof token === "string" ? await antisyVerdict(token ?? "") : "deny";
  if (verdict === "deny") {
    return new Response("Envoi refusé.", { status: 403 }); // generic refusal, without saying why
  }

  // … save or send the message
  return new Response(null, { status: 303, headers: { Location: "/merci" } });
}

  • request.formData() reads a form sent as application/x-www-form-urlencoded as well as multipart/form-data.
  • The field is read with an empty default value: if the script is blocked, it is missing, and Antisy reads an empty token as a missing token.
  • The refusal is a generic 403 response, without the cause.

Never cache a function that calls the verification (a home-made memo, unstable_cache, the "use cache" directive): a verdict holds for one token, and for it alone.

5. With a CSP policy

If your application sends a Content-Security-Policy, add https://api.antisy.com to script-src and to connect-src, and 'wasm-unsafe-eval' to script-src. With a nonce per response, the tag carries it:

app/layout.tsx, with a nonce
// app/layout.tsx: with a nonce-based CSP policy set by proxy.ts (middleware.ts before Next.js 16)
import { headers } from "next/headers";
import type { ReactNode } from "react";

export default async function RootLayout({ children }: { children: ReactNode }) {
  const nonce = (await headers()).get("x-nonce") ?? undefined;
  return (
    <html lang="fr">
      <head>
        <script src="https://api.antisy.com/v1/i/l.js" data-site={process.env.ANTISY_SITE_KEY} defer nonce={nonce} />
      </head>
      <body>{children}</body>
    </html>
  );
}

Next.js needs dynamic rendering for a nonce: reading headers() makes the layout dynamic. The three nonce-based policies that work, without 'unsafe-inline', are on the page on the script.

See: Documentation: CSP policy

What does the verification answer?

The response is a JSON object. success is true if and only if verdict is allow. There are only these two outcomes, and the response never gives the reason: the refusal you show the visitor is generic too.

A token is valid for 60 seconds and can be verified only once. A second call with the same token is refused.

Response of POST /v1/siteverify (HTTP 200)
{
  "success": true,
  "verdict": "allow",
  "flags": [],
  "error-codes": [],
  "challenge_ts": "2026-09-24T10:00:00Z",
  "hostname": "shop.example",
  "action": "contact",
  "event_id": "evt_01J7X4M2Q9K8ZP3R5T6V7W8Y9A"
}

See: Documentation: the request, the response and the error codes

What if Antisy does not answer?

The function lets the submission through in one case only: Antisy does not answer (network down, deadline of 3 seconds exceeded, 5xx error). An Antisy outage must not close your form.

Any other HTTP error (400, 401, 413, 415) comes from your call: the function refuses the submission and logs it, never a pass. A 429 is a decision that is yours: the constant ON_RATE_LIMIT is allow by default; set deny for a sensitive form.

See: Documentation: the request, the response and the error codes

What happens if the script is blocked?

A CSP policy that refuses the script, an ad blocker, a page served over HTTP or a visitor without JavaScript stop the script from running. The form is then sent anyway, without the antisy-token field: read it with an empty default value.

What follows depends on the site’s “Collecte du navigateur” (browser collection) setting, in the dashboard, which is in French. Collection on, a submission without a token does not get through; collection off, a missing token is not held against it. A new site starts with collection off.

See: Documentation: if the script does not run

What a bot check does not replace

No bot check is perfect, and Antisy announces no detection rate. It adds to the other defences of a form, such as limiting the number of submissions; it does not replace them.

Frequently asked questions

Can the Antisy secret go in a NEXT_PUBLIC_ variable?

No. A NEXT_PUBLIC_ variable is copied into the code sent to the browser: the secret would be readable there by any visitor. Only the site key is public; the secret stays in a variable of the server, read by the route.

Is there an Antisy package for Next.js?

The Node server kit (Express, Fastify, Next.js) will be published on npm; it is not yet. For a simple form, the function of the documentation is enough: a fetch to /v1/siteverify, without a dependency. The kit serves login and sign-up first.

Can the token be verified in a Server Action?

The verification function does not depend on a route: it takes a token and returns allow or deny, wherever it is called. What has not been tried is how the script handles a form sent by a Server Action. If in doubt, ask for the token yourself with antisy.token().

See also: Protect a PHP form from spam bots, without a puzzle · Protect a WordPress form from spam bots, without a plugin · All guides · Documentation: step-by-step guide