Docs

Next.js

Check signups in Next.js

Check each signup in your Next.js app before you create the user. Every file the code needs is on this page.

Install

Use a route handler for a signup API route, or a server action with its form. The form shows the error with useActionState. Both use lib/signupbouncer-fetch.ts.

  1. Save the first file below in your project as lib/signupbouncer-fetch.ts.
  2. Create an API key for the app on its Integration tab in your dashboard, then set SIGNUPBOUNCER_API_KEY in your server's environment to that key (sb_live_...).
  3. Add the route handler, or the server action and the form, and create the user where the comment says.

lib/signupbouncer-fetch.ts

// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/code/nextjs
// lib/signupbouncer-fetch.ts: checks one signup with SignupBouncer. Works in Node 18+, Deno, Bun, Next.js, Cloudflare
// Workers and any runtime with fetch. It reads SIGNUPBOUNCER_API_KEY from process.env, or takes the key as its second
// argument (in a Cloudflare Worker, pass the secret from env, because Workers have no process.env by default).
// Leave SIGNUPBOUNCER_BASE_URL unset. It points the helper at another copy of the API, such as one on your machine.
//
// The call has a 1.5-second limit, and any failure lets the signup through: a timeout, a network error, a non-2xx
// answer or an unreadable body. Only a complete, consistent enforce-mode block answer refuses the signup: then it
// returns the message to show, the one you set on your dashboard's Rules tab. With our default message, we word it to
// fit the reason, such as "Did you mean ann@gmail.com?" for a typo domain. Otherwise it returns null.
// signupRefusalWithLink returns null in the same cases, or { message, whyUrl }: the same message and, when you turn
// on the "Why was this refused?" link on the Rules tab, the link to show after it (whyUrl is null otherwise).
//
// confirmationRefusal reports an email confirmation for the second look at challenged signups (POST /v1/confirm).
// It has a 0.8-second limit and fails open the same way, a 4xx answer included: the confirmation goes through.

export interface Signup {
  email?: string;
  /** The client's IP, from a header your host sets. Leave it out if you are unsure. */
  ip?: string;
  /** Only for OAuth/SSO signups: the provider id ("google", "github", "google.com", ...). Send emailVerified with it. */
  authProvider?: string;
  /**
   * Your auth platform's own flag that the provider verified this address (an OIDC email_verified claim, Firebase's
   * emailVerified). Never what the user typed. Without true, the email is checked too.
   */
  emailVerified?: boolean;
}

const MAX_ANSWER_BYTES = 65536; // a real answer is far smaller
const DEFAULT_MESSAGE = "Please sign up with a permanent email address.";

const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
const number = (v: unknown) => typeof v === "number" && Number.isFinite(v);
const string = (v: unknown) => typeof v === "string";
const nullableString = (v: unknown) => v === null || string(v);

/** Only a complete, consistent answer may refuse a signup. Partial/error answers fail open. */
function isBlock(v: unknown): boolean {
  if (!object(v) || v.action !== "block" || v.verdict !== "block" || v.mode !== "enforce") return false;
  if (v.quota_exceeded !== false || "error" in v) return false;
  if (!number(v.score) || !string(v.check_id) || !number(v.latency_ms)) return false;
  if (
    !Array.isArray(v.reasons) ||
    !v.reasons.every((r) => object(r) && string(r.code) && string(r.detail) && number(r.points) && string(r.tier))
  ) return false;
  if (
    !Array.isArray(v.shadow) ||
    !v.shadow.every((r) => object(r) && string(r.code) && string(r.detail) && string(r.tier))
  ) return false;
  const n = v.normalized;
  if (!object(n) || !nullableString(n.provider) || !number(n.repeat_signups_7d)) return false;
  const net = v.network;
  return net === null ||
    (object(net) && (net.asn === null || number(net.asn)) && nullableString(net.asn_org) &&
      Array.isArray(net.kinds) && net.kinds.every(string) && nullableString(net.label) && string(net.prefix));
}

/** A variable from process.env, or undefined where there is none (a Worker) or reading it is not allowed (Deno). */
function env(name: string): string | undefined {
  try {
    return typeof process === "undefined" ? undefined : process.env[name];
  } catch {
    return undefined;
  }
}

/** The message sent with a block answer (your Rules tab's, or our default for the reason); ours if it is missing. */
function messageOf(v: Record<string, unknown>): string {
  const m = v.block_message;
  return typeof m === "string" && m.trim() !== "" && m.length <= 500 ? m : DEFAULT_MESSAGE;
}

/**
 * The why_url sent with a block answer when the "Why was this refused?" link is on, unchanged; null without one. Only
 * a well-formed http(s) URL with a host counts, so a bad value never becomes a link.
 */
function whyUrlOf(v: Record<string, unknown>): string | null {
  const u = v.why_url;
  if (typeof u !== "string" || u.length > 2048 || !/^https?:\/\/[^\s/\\?#][^\s\\]*$/i.test(u)) return null;
  try {
    const url = new URL(u);
    return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "" ? u : null;
  } catch {
    return null;
  }
}

export interface Refusal {
  /** The message to show: the one from your dashboard's Rules tab, or our default worded for the reason. */
  message: string;
  /** Our "Why was this refused?" page for the email's domain, only when you turn the link on. Otherwise null. */
  whyUrl: string | null;
}

/** Returns the message to show when the signup should be refused, or null to let it through. Never throws. */
export async function signupRefusal(signup: Signup, apiKey?: string): Promise<string | null> {
  return (await signupRefusalWithLink(signup, apiKey))?.message ?? null;
}

/** Like signupRefusal, plus the "Why was this refused?" link when the answer carries one. Never throws. */
export async function signupRefusalWithLink(signup: Signup, apiKey?: string): Promise<Refusal | null> {
  // No real address is longer than 320 characters. Refusing them here keeps the request small enough to be checked.
  if ((signup.email?.length ?? 0) > 320) return { message: DEFAULT_MESSAGE, whyUrl: null };
  try {
    const key = apiKey ?? env("SIGNUPBOUNCER_API_KEY");
    const base = (env("SIGNUPBOUNCER_BASE_URL") || "https://signupbouncer.com").replace(/\/+$/, "");
    const res = await fetch(`${base}/v1/check`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${key}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        email: signup.email,
        ip: signup.ip,
        auth_provider: signup.authProvider,
        email_verified: signup.emailVerified === true ? true : undefined,
      }),
      redirect: "manual", // a redirect is a failed check: your key and the address go nowhere else
      signal: AbortSignal.timeout(1500), // covers reading the answer too
    });
    const answer = await readAnswer(res);
    return isBlock(answer)
      ? { message: messageOf(answer as Record<string, unknown>), whyUrl: whyUrlOf(answer as Record<string, unknown>) }
      : null;
  } catch {
    return null;
  }
}

/** The JSON of a 2xx answer, read in pieces up to MAX_ANSWER_BYTES; null for anything else. Throws on bad JSON. */
async function readAnswer(res: Response): Promise<unknown> {
  if (!res.ok || !res.body) {
    await res.body?.cancel();
    return null;
  }
  // Read the answer in pieces and stop as soon as it is larger than any real answer.
  const reader = res.body.getReader();
  const parts: Uint8Array[] = [];
  let size = 0;
  for (let r = await reader.read(); !r.done; r = await reader.read()) {
    size += r.value.byteLength;
    if (size > MAX_ANSWER_BYTES) {
      await reader.cancel();
      return null;
    }
    parts.push(r.value);
  }
  const body = new Uint8Array(size);
  parts.reduce((at, p) => (body.set(p, at), at + p.byteLength), 0);
  return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
}

// ---- Second look at email confirmation (POST /v1/confirm) ----------------------------------------------------------

export interface Confirmation {
  /** The check_id that /v1/check answered for this signup, if you kept it. */
  checkId?: string;
  /** Supabase: the id of the user who confirmed (data.user.id from verifyOtp). */
  supabaseUserId?: string;
  /** Without a check id: the address that was confirmed, as your auth platform has it. */
  email?: string;
  /** The IP of the request that confirmed, from a header your host sets. */
  ip?: string;
  /** The User-Agent header of the request that confirmed. */
  userAgent?: string;
  /**
   * How the person confirmed: "button" when they pressed a button on your own page, "code" when they typed a code,
   * "link" (the default) when the request is the click on the emailed link itself. Mail scanners open links too, so
   * on "link" we score only what the counters found since the check, never the IP or browser.
   */
  via?: "button" | "code" | "link";
}

/**
 * Only a complete enforce-mode block from a second look refuses a confirmation. A second look that did not happen
 * (second_look false), an error answer or anything partial lets it through.
 */
function isConfirmBlock(v: unknown): boolean {
  if (!object(v) || v.action !== "block" || v.verdict !== "block" || v.mode !== "enforce") return false;
  if (v.second_look !== true || "error" in v) return false;
  return number(v.score) && string(v.check_id) && Array.isArray(v.reasons);
}

/**
 * Reports an email confirmation and returns the message to show when the confirmation should be refused, or null to
 * let it through. Never throws. Any failure, a timeout, a 4xx (an unknown or expired check) or a 5xx lets it through.
 */
export async function confirmationRefusal(confirmation: Confirmation, apiKey?: string): Promise<string | null> {
  try {
    const key = apiKey ?? env("SIGNUPBOUNCER_API_KEY");
    const base = (env("SIGNUPBOUNCER_BASE_URL") || "https://signupbouncer.com").replace(/\/+$/, "");
    const res = await fetch(`${base}/v1/confirm`, {
      method: "POST",
      headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
      body: JSON.stringify({
        check_id: confirmation.checkId,
        supabase_user_id: confirmation.supabaseUserId,
        email: confirmation.email,
        ip: confirmation.ip,
        user_agent: confirmation.userAgent,
        via: confirmation.via ?? "link",
      }),
      redirect: "manual", // a redirect is a failed call: your key goes nowhere else
      // Short: the person is waiting on your confirmation page. Covers reading the answer too.
      signal: AbortSignal.timeout(800),
    });
    const answer = await readAnswer(res);
    return isConfirmBlock(answer) ? messageOf(answer as Record<string, unknown>) : null;
  } catch {
    return null;
  }
}

app/api/signup/route.ts

// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/code/nextjs
// app/api/signup/route.ts: a Next.js App Router route handler that checks the signup before creating the user.
import { signupRefusalWithLink } from "@/lib/signupbouncer-fetch";

export async function POST(req: Request) {
  const { email } = await req.json();
  // On Vercel, x-real-ip holds the client IP. On other hosts, use the header your host sets, or leave ip out.
  const ip = req.headers.get("x-real-ip") ?? undefined;
  const refusal = await signupRefusalWithLink({ email, ip });
  if (refusal) {
    // why_url is there only when you turn on the "Why was this refused?" link on your dashboard's Rules tab.
    const why = refusal.whyUrl ? { why_url: refusal.whyUrl } : {};
    return Response.json({ error: refusal.message, ...why }, { status: 400 });
  }
  // Create the user with your auth library here.
  return Response.json({ ok: true });
}

app/signup/actions.ts

// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/code/nextjs
// app/signup/actions.ts: a Next.js server action for the signup form in form.tsx (useActionState).
"use server";

import { headers } from "next/headers";
import { signupRefusalWithLink } from "@/lib/signupbouncer-fetch";

// whyUrl is set only when you turn on the "Why was this refused?" link on your dashboard's Rules tab.
export type SignUpState = { error?: string; whyUrl?: string };

export async function signUp(_previous: SignUpState, formData: FormData): Promise<SignUpState> {
  const email = String(formData.get("email") ?? "");
  // On Vercel, x-real-ip holds the client IP. On other hosts, use the header your host sets, or leave ip out.
  const ip = (await headers()).get("x-real-ip") ?? undefined;
  const refusal = await signupRefusalWithLink({ email, ip });
  if (refusal) return refusal.whyUrl ? { error: refusal.message, whyUrl: refusal.whyUrl } : { error: refusal.message };
  // Create the user with your auth library here, then redirect() to your app.
  return {};
}

app/signup/form.tsx

// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/code/nextjs
// app/signup/form.tsx: the signup form for the server action in actions.ts. It shows the action's error message, and
// the "Why was this refused?" link when the action returns one.
"use client";

import { useActionState } from "react";
import { signUp, type SignUpState } from "./actions";

const initial: SignUpState = {};

export function SignUpForm() {
  const [state, formAction, pending] = useActionState(signUp, initial);
  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit" disabled={pending}>Sign up</button>
      {state.error && (
        <p role="alert">
          {state.error}
          {state.whyUrl && (
            <>
              {" "}
              <a href={state.whyUrl}>Why was this refused?</a>
            </>
          )}
        </p>
      )}
    </form>
  );
}
  • On Vercel, x-real-ip holds the client IP. Send the IP only from a header your host overwrites with the client's address, because the client can set a header that your host passes through unchanged. If you're unsure, leave ip out and we'll still check the email.

What the code does when a check fails

In every snippet, the call has a 1.5-second limit, and the code lets the signup through on any failure: a timeout, a network error, a non-2xx answer or an unreadable or incomplete body. It refuses a signup only after a complete answer with "action" and "verdict" both "block", "mode" set to "enforce", and no quota or error flag. The exception is an address that can't be real, which the code refuses without a call: one over 320 characters, or, in the Better Auth plugin, one that isn't an address at all. New apps start in shadow mode, where "action" is always "allow". So the code refuses no other signup until you switch the app to enforce in your dashboard. Each snippet checks only the signups that pass through it. So if your app also creates users somewhere else, such as an OAuth callback or an admin tool, call the check there too.

Shadow mode first

New SignupBouncer apps start in shadow mode. We check and log each signup your code sends, and refuse none of them.

Sign up a few times through your form, once with an address at mailinator.com. Test signups count toward your monthly checks. On the Checks tab, the mailinator.com signup shows as block, and its account exists all the same.

When the results look right, open your app's Rules tab. Choose Enforce next to Mode, then Turn on blocking in the window that opens.

What a refused user sees

In enforce mode, the server action returns the block message from your Rules tab, and the form shows it below the button. The route handler answers 400 with the message in error.

When you turn on the "Why was this refused?" link on the Rules tab, the form shows the link after the message. The route handler's answer holds the link in why_url.

Repeat signups from the same inbox

We count how many times an inbox signed up over the last 7 days, across every app on your account. With the default cutoffs, we mark the third and fourth signups from that inbox as challenge, and those people still get accounts. Your form refuses the fifth signup from that inbox.

Signups the check doesn't stop

  • If your app also creates users in an OAuth callback or an admin tool, call the check in those places too.
  • A person with real inboxes at different providers, say a Gmail and an Outlook address, gets an account with each.
  • Add email verification to your signup flow to confirm that the person owns the email address. SignupBouncer's check doesn't confirm ownership.
  • On the Free plan, after 1,000 checks in a month, signups go through unchecked until the month ends.
  • We test the route handler and the server action against our API with Deno. In those tests, a stand-in replaces next/headers. Our Next.js SaaS starter guide runs the check in a real Next.js 16 app.