{}</>
Developer10 min readApril 7, 2026

Building a Signup Form That Actually Prevents Fraud (Next.js + BigShield)

A practical tutorial for building a Next.js signup form with real-time email fraud detection using BigShield. Full code examples with progressive enhancement and graceful degradation.

The Problem With Most Signup Forms

Most signup forms do the bare minimum: check if the email looks valid with a regex, maybe confirm the passwords match, and call it a day. The result? Bots and fraudsters walk right through, creating fake accounts that abuse free tiers, pollute your database, and cost real money.

In this tutorial, we will build a Next.js signup form that validates emails against BigShield's fraud detection API in real time. The form will give users immediate feedback, gracefully handle API failures, and progressively enhance the experience without blocking legitimate signups.

If you are new to email validation APIs, our developer's guide to email validation covers the fundamentals.

What We Are Building

Here is the plan:

  • A React signup form component with email, name, and password fields
  • A server-side API route that validates the email through BigShield
  • Real-time feedback as the user types (debounced, not on every keystroke)
  • Progressive enhancement: the form works even if BigShield is unreachable
  • Score-based decisions: auto-accept high scores, auto-reject low scores, flag the middle for review

Prerequisites

You will need a Next.js 14+ project with the App Router, a BigShield account with an API key, and the BigShield SDK installed:

npm install @bigshield/sdk

Step 1: The API Route

First, create the server-side route that talks to BigShield. Never call the BigShield API from the client, because that would expose your API key.

// app/api/validate-email/route.ts
import { BigShield } from '@bigshield/sdk';
import { NextRequest, NextResponse } from 'next/server';

const bigshield = new BigShield({
  apiKey: process.env.BIGSHIELD_API_KEY!,
});

export async function POST(req: NextRequest) {
  try {
    const { email } = await req.json();

    if (!email || typeof email !== 'string') {
      return NextResponse.json(
        { error: 'Email is required' },
        { status: 400 }
      );
    }

    const result = await bigshield.validate({
      email,
      ip: req.headers.get('x-forwarded-for') || undefined,
      userAgent: req.headers.get('user-agent') || undefined,
    });

    // Return only what the client needs
    return NextResponse.json({
      score: result.score,
      verdict: result.verdict, // 'allow' | 'deny' | 'review'
      reasons: result.signals
        .filter((s) => s.severity === 'high')
        .map((s) => s.label),
    });
  } catch (error) {
    console.error('BigShield validation error:', error);
    // Fail open: do not block signups if validation is down
    return NextResponse.json({
      score: null,
      verdict: 'allow',
      reasons: [],
      degraded: true,
    });
  }
}

Notice the error handling. If BigShield is unreachable for any reason, we fail open and allow the signup. This is a critical design decision. You never want your fraud prevention layer to become a single point of failure that blocks every legitimate user.

Step 2: The Validation Hook

Next, create a custom hook that debounces the email validation. We do not want to fire an API call on every keystroke, so we wait until the user pauses typing.

// hooks/use-email-validation.ts
'use client';

import { useState, useEffect, useRef } from 'react';

interface ValidationResult {
  score: number | null;
  verdict: 'allow' | 'deny' | 'review';
  reasons: string[];
  degraded?: boolean;
}

interface ValidationState {
  isValidating: boolean;
  result: ValidationResult | null;
  error: string | null;
}

export function useEmailValidation(email: string, debounceMs = 600) {
  const [state, setState] = useState<ValidationState>({
    isValidating: false,
    result: null,
    error: null,
  });
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    // Basic client-side check before hitting the API
    const looksLikeEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
    if (!email || !looksLikeEmail) {
      setState({ isValidating: false, result: null, error: null });
      return;
    }

    setState((prev) => ({ ...prev, isValidating: true }));

    const timeout = setTimeout(async () => {
      // Cancel any in-flight request
      abortRef.current?.abort();
      const controller = new AbortController();
      abortRef.current = controller;

      try {
        const res = await fetch('/api/validate-email', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ email }),
          signal: controller.signal,
        });

        if (!res.ok) throw new Error('Validation request failed');

        const data: ValidationResult = await res.json();
        setState({ isValidating: false, result: data, error: null });
      } catch (err: any) {
        if (err.name === 'AbortError') return;
        setState({
          isValidating: false,
          result: null,
          error: 'Could not validate email',
        });
      }
    }, debounceMs);

    return () => {
      clearTimeout(timeout);
      abortRef.current?.abort();
    };
  }, [email, debounceMs]);

  return state;
}

Step 3: The Feedback Component

This small component shows the validation status inline beneath the email field:

// components/email-feedback.tsx
'use client';

interface EmailFeedbackProps {
  isValidating: boolean;
  result: {
    score: number | null;
    verdict: string;
    reasons: string[];
    degraded?: boolean;
  } | null;
  error: string | null;
}

export function EmailFeedback({
  isValidating,
  result,
  error,
}: EmailFeedbackProps) {
  if (isValidating) {
    return (
      <p className="text-sm text-gray-400 mt-1">
        Checking email...
      </p>
    );
  }

  if (error) {
    // Do not alarm the user if validation is just unavailable
    return null;
  }

  if (!result) return null;

  if (result.verdict === 'deny') {
    return (
      <p className="text-sm text-red-400 mt-1">
        This email address cannot be used for signup.
        {result.reasons.length > 0 && (
          <span className="block text-xs text-red-400/70 mt-0.5">
            Reason: {result.reasons[0]}
          </span>
        )}
      </p>
    );
  }

  if (result.verdict === 'review') {
    return (
      <p className="text-sm text-yellow-400 mt-1">
        We may need to verify your account manually.
      </p>
    );
  }

  if (result.score !== null && result.score >= 70) {
    return (
      <p className="text-sm text-green-400 mt-1">
        Email looks good!
      </p>
    );
  }

  return null;
}

Step 4: The Signup Form

Now we bring it all together into a complete signup form:

// components/signup-form.tsx
'use client';

import { useState, FormEvent } from 'react';
import { useEmailValidation } from '@/hooks/use-email-validation';
import { EmailFeedback } from '@/components/email-feedback';

export function SignupForm() {
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');
  const [password, setPassword] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState<string | null>(null);

  const validation = useEmailValidation(email);

  const isBlocked = validation.result?.verdict === 'deny';
  const canSubmit =
    email && name && password.length >= 8 && !isBlocked && !isSubmitting;

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!canSubmit) return;

    setIsSubmitting(true);
    setSubmitError(null);

    try {
      const res = await fetch('/api/auth/signup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          email,
          name,
          password,
          // Pass the validation result so the server can
          // store the score and decide on review queues
          emailScore: validation.result?.score,
          emailVerdict: validation.result?.verdict,
        }),
      });

      if (!res.ok) {
        const data = await res.json();
        throw new Error(data.error || 'Signup failed');
      }

      // Redirect to onboarding or dashboard
      window.location.href = '/onboarding';
    } catch (err: any) {
      setSubmitError(err.message);
    } finally {
      setIsSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4 max-w-md">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          Name
        </label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          required
          className="mt-1 w-full rounded-md border border-gray-700
                     bg-gray-900 px-3 py-2 text-white"
        />
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          className="mt-1 w-full rounded-md border border-gray-700
                     bg-gray-900 px-3 py-2 text-white"
        />
        <EmailFeedback
          isValidating={validation.isValidating}
          result={validation.result}
          error={validation.error}
        />
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          Password
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          minLength={8}
          className="mt-1 w-full rounded-md border border-gray-700
                     bg-gray-900 px-3 py-2 text-white"
        />
      </div>

      {submitError && (
        <p className="text-sm text-red-400">{submitError}</p>
      )}

      <button
        type="submit"
        disabled={!canSubmit}
        className="w-full rounded-md bg-green-600 px-4 py-2 font-medium
                   text-white hover:bg-green-500 disabled:opacity-50
                   disabled:cursor-not-allowed"
      >
        {isSubmitting ? 'Creating account...' : 'Create account'}
      </button>
    </form>
  );
}

Step 5: Server-Side Validation on Submit

The client-side check provides instant feedback, but you must also validate server-side when the form is submitted. Never trust client-sent scores, because they can be spoofed.

// app/api/auth/signup/route.ts
import { BigShield } from '@bigshield/sdk';
import { NextRequest, NextResponse } from 'next/server';
import { createUser, flagForReview } from '@/lib/auth';

const bigshield = new BigShield({
  apiKey: process.env.BIGSHIELD_API_KEY!,
});

export async function POST(req: NextRequest) {
  const { email, name, password } = await req.json();

  // Server-side BigShield validation (authoritative)
  const validation = await bigshield.validate({
    email,
    ip: req.headers.get('x-forwarded-for') || undefined,
    userAgent: req.headers.get('user-agent') || undefined,
  });

  // Hard block: score below 30
  if (validation.score < 30) {
    return NextResponse.json(
      { error: 'This email address is not eligible for signup.' },
      { status: 422 }
    );
  }

  // Create the user
  const user = await createUser({ email, name, password });

  // Soft flag: score between 30 and 69
  if (validation.score < 70) {
    await flagForReview(user.id, {
      score: validation.score,
      signals: validation.signals,
    });
  }

  return NextResponse.json({ user: { id: user.id, email: user.email } });
}

Design Decisions Worth Noting

Fail Open, Not Closed

If BigShield returns an error or times out, the form allows the signup. You can always review these accounts later. Blocking every signup because your fraud detection is having a bad moment is far worse than letting a few suspicious accounts through.

Debounce, Do Not Throttle

We wait 600ms after the user stops typing before firing the API call. This prevents unnecessary requests while still feeling responsive. If the user is still actively typing, we wait.

Three-Tier Decision Making

Rather than a binary allow/deny, we use three tiers based on score:

  • Score 70-100: Auto-approve. The signup sails through.
  • Score 30-69: Allow but flag. The user gets in, but the account goes into a review queue for manual inspection.
  • Score 0-29: Block. The email is almost certainly fraudulent.

This approach minimizes false positives while still catching obvious fraud. For more on handling the middle ground, check out our guide on post-signup bot detection.

Score Storage

Store the BigShield score with the user record. This lets you analyze patterns later, tune your thresholds, and build dashboards showing fraud trends over time.

Progressive Enhancement in Action

Here is how the form behaves at each level of capability:

  1. JavaScript disabled: Form submits normally. Server-side validation catches fraud. No real-time feedback, but fraud prevention still works.
  2. BigShield API down: Real-time feedback disappears. Form works normally. Server-side validation fails open. Accounts flagged for manual review.
  3. Everything working: Real-time feedback shows while typing. Server-side validates authoritatively. Full fraud prevention active.

Testing Your Integration

BigShield provides test API keys that return predictable scores. Use these during development:

// .env.local
BIGSHIELD_API_KEY=ev_test_your_test_key_here

Test emails with specific patterns return specific scores in test mode:

  • test-allow@example.com returns score 95
  • test-review@example.com returns score 50
  • test-deny@example.com returns score 12

What You Have Built

In about 200 lines of code, you now have a signup form that validates emails against 20+ fraud signals in real time, gives users immediate visual feedback, gracefully handles failures without blocking legitimate signups, and uses a three-tier system to balance security with user experience.

This is the kind of signup flow that separates apps that hemorrhage money to fraud from apps that catch it early. BigShield handles the hard part (the signal analysis and scoring), so you can focus on building your product. Get your API key at bigshield.app and start protecting your signups today.

Ready to stop fake signups?

BigShield validates emails with 20+ signals in under 200ms. Start for free, no credit card required.

Get Started Free

Related Articles