Next.js gives you three plausible places to run a fraud check — middleware.ts, a Route Handler, or a Server Action — and only two of them work. This walks through a real integration, including the mistake that costs people a week.
The short version: the check needs a token collected in the browser, so it cannot live in Edge middleware that runs before your form is submitted. Put it where the action happens.
Why middleware is the wrong place
Edge middleware runs on every matching request, before your route code, with access to headers and cookies but not to a form body. That is fine for coarse routing decisions and terrible for fraud detection, because the signals that matter are collected client-side and submitted with the request.
The device layer in particular — antidetect browser traits, automation surfaces, tampering score — is only available once a browser has run the collector. Middleware sees the request too early for any of that to exist.
There is also a cost argument. Middleware runs on every request including static assets and prefetches, so putting a paid API call there burns quota on traffic that was never a decision point.
Step 1: collect signals in the browser
Load the SDK once in your root layout. It captures network and device signals and injects two hidden fields into any form carrying class="monocle-enriched".
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://sntlhq.com/assets/sentinel.js" strategy="afterInteractive" />
</body>
</html>
);
}
Step 2: read the fields on submit
For a classic form post, add the class and the SDK does the rest — monocle is the network token, sentinel_fp is the device event id.
For a fetch or client-component submit, collect them explicitly instead:
'use client';
export function SignupForm() {
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
// window.Sentinel is provided by the SDK script
const { token, fingerprintEventId } = await (window as any).Sentinel.collect();
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: (e.currentTarget.elements.namedItem('email') as HTMLInputElement).value,
token,
fingerprintEventId,
}),
});
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<button type="submit">Create account</button>
</form>
);
}
Step 3: decide server-side in a Route Handler
This is where the verdict is made. The API key never reaches the browser.
// app/api/signup/route.ts
import { NextResponse } from 'next/server';
import Sentinel from '@sentinelsup/sdk';
const sentinel = new Sentinel({ apiKey: process.env.SENTINEL_KEY! });
export async function POST(req: Request) {
const { email, token, fingerprintEventId } = await req.json();
let verdict;
try {
verdict = await sentinel.evaluate({
token,
fingerprintEventId, // omit and the device signals never fire
email, // adds email.disposable; checked transiently
accountId: undefined, // set post-creation to enable multi-account linking
});
} catch (err) {
// Fail open. A detection outage must not become a signup outage.
console.error('[sentinel]', err);
return NextResponse.json({ ok: true, degraded: true });
}
// Route on decision — not on isSuspicious, which does not cover
// Tor or datacenter origins.
if (verdict.decision === 'block') {
return NextResponse.json({ error: 'Signup unavailable' }, { status: 403 });
}
if (verdict.decision === 'review') {
await createAccount({ email, requiresEmailVerification: true });
return NextResponse.json({ ok: true, stepUp: true });
}
await createAccount({ email, requiresEmailVerification: false });
return NextResponse.json({ ok: true });
}
Server Actions work too
If you are using Server Actions rather than Route Handlers, the shape is the same — the token still has to come from the client, so pass it through the form.
// app/actions.ts
'use server';
import Sentinel from '@sentinelsup/sdk';
const sentinel = new Sentinel({ apiKey: process.env.SENTINEL_KEY! });
export async function signup(formData: FormData) {
const token = String(formData.get('monocle') ?? '');
const fingerprintEventId = String(formData.get('sentinel_fp') ?? '');
if (!token) return { ok: true, degraded: true }; // SDK blocked; do not hard-fail
const verdict = await sentinel.evaluate({ token, fingerprintEventId });
if (verdict.decision === 'block') return { error: 'Signup unavailable' };
// ...
}
Four things that bite people
- Gating on
isSuspicious. It is a legacy convenience flag that does not cover Tor or datacenter origins. The SDK types say so explicitly. Route ondecision. - Forgetting
fingerprintEventId. Without it the response has nodeviceblock at all, so antidetect and automation detection silently never fire and you conclude the product does not work. - Failing closed. If the API call throws, let the signup through and log it. A detection outage that becomes a signup outage is a worse incident than the fraud you were preventing.
- Blocking on
review. The review band exists so you can step up — email verification, a second factor — rather than refuse. Reserve hard blocks for the score band that earns them.
Where to put the strictest rule
Signup is the obvious place to start and rarely where the money is. Promotion and referral redemption is where losses concentrate, and it is the action with essentially no legitimate automated use case.
Once accounts exist, pass accountId on subsequent calls. That is what enables device-to-account linking, which is how you catch one environment behind forty accounts — the pattern that no per-request score will ever surface on its own.
Frequently Asked Questions
Can I run fraud detection in Next.js middleware?
Not usefully. Edge middleware runs before your route code with access to headers and cookies but not the form body, and the device-layer signals — antidetect traits, automation surfaces, tampering score — only exist after a browser has run the client collector. Middleware sees the request too early for any of them. It also runs on every matching request including prefetches and static assets, so a paid API call there burns quota on traffic that was never a decision point. Use a Route Handler or Server Action instead.
Should I gate on decision or isSuspicious?
On decision. isSuspicious is a legacy convenience flag covering VPN, proxy, antidetect, automation and emulator, and the SDK type definitions state plainly that it does not cover Tor or datacenter origins. Gating on it therefore misses two whole categories while also ignoring any custom rules or allow-pins you configured, since those are applied to decision. The three values are allow, review, and block.
What happens if the Sentinel API is down?
You should let the request through. Wrap the call in try/catch, log the failure, and continue — a detection outage that turns into a signup or checkout outage is a more expensive incident than the fraud it would have caught. The SDK throws SentinelError on network failure, timeout, or a non-2xx response, so a single catch block covers every failure mode.
Do I need the fingerprintEventId if I already send the token?
You need it for any device-layer detection. The token carries network signals; the fingerprint event id is what enables the device block in the response, including antidetect browser detection, automation detection, emulator and virtual machine flags, and the tampering score. Omit it and the response simply has no device object, which is the single most common reason an integration appears to detect nothing.
Wire it into your Next.js app
Free tier: 1,000 requests/hour, no card, no expiry.
Try Sentinel free →