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/sdkStep 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:
- JavaScript disabled: Form submits normally. Server-side validation catches fraud. No real-time feedback, but fraud prevention still works.
- BigShield API down: Real-time feedback disappears. Form works normally. Server-side validation fails open. Accounts flagged for manual review.
- 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_hereTest emails with specific patterns return specific scores in test mode:
test-allow@example.comreturns score 95test-review@example.comreturns score 50test-deny@example.comreturns 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.