Farm.js

Bot Protection

@farm.js/bot stops bots at your forms. The check runs in the browser and every submission is verified on the server, so a form that skips the check is refused. Secrets stay on the server, and your security.csp is updated for the provider you pick.

It works with any checker. @farm.js/bot/captcha has Turnstile, hCaptcha, and reCAPTCHA, and you can write your own in a few lines. Provider code you don't import stays out of your bundle.

It doesn't rate limit, block IPs, or decide what to do with a suspicious visitor. It answers one question for one submission.

Install

pnpm add @farm.js/bot

Complete example

Add the plugin with the provider you want:

farm.config.ts
import { bot } from "@farm.js/bot";
import { turnstile } from "@farm.js/bot/captcha";
import { defineConfig } from "@farm.js/core";

export default defineConfig({
  plugins: [bot({ provider: turnstile() })],
});

Show the check inside the form:

src/app/signup/page.tsx
import { useRef, useState, type FormEvent } from "react";
import { BotCheck, type BotCheckHandle } from "@farm.js/bot/react";

export default function SignupPage() {
  const check = useRef<BotCheckHandle>(null);
  const [verified, setVerified] = useState(false);

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    await fetch("/api/signup", { method: "POST", body: new FormData(event.currentTarget) });
    // Tokens are single-use, so every attempt needs a new one.
    setVerified(false);
    check.current?.reset();
  }

  return (
    <form onSubmit={submit}>
      <input name="email" type="email" required />
      <BotCheck
        ref={check}
        onVerify={() => setVerified(true)}
        onExpire={() => setVerified(false)}
      />
      <button disabled={!verified}>Sign up</button>
    </form>
  );
}

Verify on the server. verifyBot works the same in API routes and server actions:

src/app/api/signup/route.ts
import { verifyBot } from "@farm.js/bot/server";

export async function POST(request: Request) {
  const form = await request.formData();
  const check = await verifyBot(form);

  if (!check.success) {
    return Response.json({ error: check.message }, { status: check.status });
  }

  // Create the account.
  return Response.json({ ok: true });
}

Options

bot({
  provider: turnstile(),
  hostnames: ["shop.example.com"],
  timeoutMs: 3000,
});
OptionDefaultDescription
providerRequiredThe checker to use.
hostnamesThe request's hostnameThe hostnames a check may come from.
timeoutMs5000How long to wait for the provider.

Captchas

ProviderSite keySecret key
turnstile()TURNSTILE_SITE_KEYTURNSTILE_SECRET_KEY
hcaptcha()HCAPTCHA_SITE_KEYHCAPTCHA_SECRET_KEY
recaptcha()RECAPTCHA_SITE_KEYRECAPTCHA_SECRET_KEY

Each takes { siteKey, secretKey, testKeys }.

In development they work with no keys, on the service's always-passing test keys. A production build needs the site key and the server needs the secret. Both fail with a message naming the variable when they're missing. A built server never uses test keys unless you pass testKeys: true, for a staging deployment.

What comes back

| { success: true,  provider, hostname, testKeys, data }
| { success: false, provider, code, message, status, providerCodes }

message is safe to show. status is 403, or 503 when the provider can't be reached. code is one of missing_token, rejected, expired, hostname_mismatch, or unavailable.

A configuration mistake, such as a missing or wrong key, is your bug and not the visitor's, so it throws a BotError and the submission fails.

verifyBot also takes a token string, for a JSON API.

Other renderers

RendererImportReset
React@farm.js/bot/reactref gives { reset() }
Preact@farm.js/bot/preactref gives { reset() }
Vue@farm.js/bot/vuetemplate ref gives reset
Solid@farm.js/bot/solidref callback
Svelte@farm.js/bot/svelteref in the action's options

Svelte uses an action: <div use:botCheck={{ onVerify }}></div>. Pass settings for anything specific to a provider, such as { size: "compact" }.

Write your own checker

A checker is a plain object. name and verify are all it needs:

import { defineBotProvider } from "@farm.js/bot";

export const honeypot = defineBotProvider({
  name: "honeypot",
  async verify({ form }) {
    return form?.get("website") ? { success: false, code: "rejected" } : { success: true };
  },
});

verify receives { token, form, request, remoteIp, signal }. Farm reads the token, enforces timeoutMs, refuses a hostname that isn't yours when you report one, writes the visitor's message, and refuses anything that isn't a valid verdict. Throw only for a configuration mistake.

For browser code, name an ordinary module in client. Call onVerify(token) from it and Farm puts the token in the form under tokenField:

// src/acme.ts
export const acme = defineBotProvider({
  name: "acme",
  tokenField: "acme-token",
  client: () => ({ widget: "/src/acme.widget.ts", public: { minMs: 800 } }),
  async verify({ token }) {
    return token === "ok" ? { success: true } : { success: false, code: "rejected" };
  },
});
// src/acme.widget.ts
import { defineBotWidget } from "@farm.js/bot/client";

export default defineBotWidget<{ minMs: number }>(({ minMs }) => ({
  mount(_element, { onVerify }) {
    const timer = setTimeout(() => onVerify("ok"), minMs);
    return { reset() {}, remove: () => clearTimeout(timer) };
  },
}));

A published checker names its widget by package path, such as acme-bot/widget. Inside an app, use a path from the app root, as above.

createBotKeys gives your checker the same key options and safety as the captcha providers. A service with a script widget and a siteverify endpoint is only a description, with createSiteverifyProvider from @farm.js/bot/captcha. The package README has both, and checkBotProvider from @farm.js/bot/testing reports every way a checker breaks the contract.

For a typed check.data, keep the plugin in a variable, add it to plugins, and call its verify, because verifyBot can't see your provider's types.

Limits

  • rsc mode. Farm runs only a plugin's browser hooks there, not its configure, so the build has no widget for <BotCheck> and the page logs a BotError that says so. A production server doesn't load farm.config.ts either, so verifyBot throws a BotError, although farm dev finds the plugin. To check forms on the server, create the plugin in a module your routes import, such as src/bot.ts, and add that instance to plugins. islands mode works.
  • Cloudflare Pages with @farm.js/core 0.1.66 or earlier. That output exposes no bindings at runtime, so secrets aren't found and every check fails with a BotError. Upgrade core, or use compatibility date 2025-04-01 or later, the cloudflare-module preset, or pass secretKey.
  • reCAPTCHA means the v2 checkbox. v3 has no widget, so it needs its own provider.
  • One checker per app. The plugin can be added once, so two forms can't use two different checkers.
  • A checker can't add routes. One that issues challenges from the server needs a route in the app for it.
  • A checker with no browser half has no <BotCheck>. Leave the component out, or nothing tells the page it passed.
  • The hostname check relies on the provider's report, which test keys don't give. By default it compares against the request's host, which a client can set on a server it reaches directly. Set hostnames for a Node server without a proxy that fixes the host, or behind one that rewrites it. Each provider's own domain allowlist still applies.