How to Add Bot Detection
to Next.js with Middleware
Next.js middleware runs before every request hits your pages or API routes. That makes it the perfect place to add bot detection — you can block malicious traffic, rate-limit suspicious IPs, and enrich requests with risk data before your application logic ever executes.
This guide walks you through integrating IPASIS bot detection into a Next.js application, from basic middleware setup to production-ready patterns with caching, selective protection, and graceful degradation.
What you'll build:
- ✅ Next.js middleware that checks IP risk on every request
- ✅ Selective protection for sensitive routes (signup, login, checkout, API)
- ✅ In-memory caching to minimize API calls and latency
- ✅ Risk-based responses (block, challenge, flag, allow)
- ✅ Graceful degradation when the API is unreachable
- ✅ Server Component integration for risk-aware rendering
Prerequisites
- Next.js 14+ (App Router recommended)
- An IPASIS API key — get one free (1,000 lookups/day, no credit card)
- Basic familiarity with Next.js middleware
Step 1: Basic Middleware Setup
Create middleware.ts in your project root (next to app/ or src/). This is the simplest working implementation:
middleware.ts — basic bot detection
import { NextRequest, NextResponse } from 'next/server';
const IPASIS_KEY = process.env.IPASIS_API_KEY!;
const IPASIS_URL = 'https://api.ipasis.com/v1/lookup';
export async function middleware(request: NextRequest) {
// Get the client IP
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| request.headers.get('x-real-ip')
|| '127.0.0.1';
// Skip private/local IPs
if (isPrivateIP(ip)) {
return NextResponse.next();
}
try {
const response = await fetch(`${IPASIS_URL}?ip=${ip}`, {
headers: { 'Authorization': `Bearer ${IPASIS_KEY}` },
// Next.js edge runtime supports fetch with timeout
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
// API error — fail open (allow the request)
return NextResponse.next();
}
const data = await response.json();
// Block high-risk traffic
if (data.risk_score > 85 && data.is_datacenter) {
return new NextResponse('Access denied', { status: 403 });
}
// Pass risk data to your app via headers
const res = NextResponse.next();
res.headers.set('x-ip-risk-score', String(data.risk_score));
res.headers.set('x-ip-is-proxy', String(data.is_proxy));
res.headers.set('x-ip-is-datacenter', String(data.is_datacenter));
res.headers.set('x-ip-country', data.country || '');
return res;
} catch (error) {
// Timeout or network error — fail open
console.error('IPASIS lookup failed:', error);
return NextResponse.next();
}
}
function isPrivateIP(ip: string): boolean {
return ip === '127.0.0.1'
|| ip === '::1'
|| ip.startsWith('10.')
|| ip.startsWith('192.168.')
|| ip.startsWith('172.16.')
|| ip.startsWith('172.17.')
|| ip.startsWith('172.18.')
|| ip.startsWith('172.19.')
|| ip.startsWith('172.2')
|| ip.startsWith('172.3');
}
// Only run middleware on specific routes
export const config = {
matcher: [
'/api/:path*',
'/signup',
'/login',
'/checkout/:path*',
],
};This works, but it has two problems: (1) it calls the IPASIS API on every request, and (2) it treats all routes the same. Let's fix both.
Step 2: Add Caching
IP risk data doesn't change every second. Caching results for 5-10 minutes dramatically reduces API calls while keeping protection effective. Next.js edge middleware doesn't have access to Redis, but we can use a simple in-memory LRU cache:
lib/ip-cache.ts — lightweight edge-compatible cache
// Simple LRU cache for edge runtime (no external dependencies)
interface CacheEntry {
data: IPRiskData;
expiresAt: number;
}
interface IPRiskData {
risk_score: number;
is_datacenter: boolean;
is_vpn: boolean;
is_proxy: boolean;
is_tor: boolean;
is_residential_proxy: boolean;
provider?: string;
country?: string;
asn?: number;
}
const cache = new Map<string, CacheEntry>();
const MAX_ENTRIES = 10_000;
const TTL_MS = 5 * 60 * 1000; // 5 minutes
export function getCachedRisk(ip: string): IPRiskData | null {
const entry = cache.get(ip);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(ip);
return null;
}
return entry.data;
}
export function setCachedRisk(ip: string, data: IPRiskData): void {
// Evict oldest entries if cache is full
if (cache.size >= MAX_ENTRIES) {
const firstKey = cache.keys().next().value;
if (firstKey) cache.delete(firstKey);
}
cache.set(ip, {
data,
expiresAt: Date.now() + TTL_MS,
});
}Now update the middleware to use the cache:
middleware.ts — with caching
import { NextRequest, NextResponse } from 'next/server';
import { getCachedRisk, setCachedRisk } from './lib/ip-cache';
const IPASIS_KEY = process.env.IPASIS_API_KEY!;
async function getIPRisk(ip: string) {
// Check cache first
const cached = getCachedRisk(ip);
if (cached) return cached;
// Fetch from IPASIS
const res = await fetch(
`https://api.ipasis.com/v1/lookup?ip=${ip}`,
{
headers: { 'Authorization': `Bearer ${IPASIS_KEY}` },
signal: AbortSignal.timeout(3000),
}
);
if (!res.ok) return null;
const data = await res.json();
setCachedRisk(ip, data);
return data;
}
export async function middleware(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| request.headers.get('x-real-ip')
|| '127.0.0.1';
if (isPrivateIP(ip)) return NextResponse.next();
const risk = await getIPRisk(ip);
// Fail open if lookup fails
if (!risk) return NextResponse.next();
// Block high-risk datacenter traffic
if (risk.risk_score > 85 && risk.is_datacenter) {
return new NextResponse(
JSON.stringify({ error: 'Suspicious traffic detected' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
// Enrich request with risk data for downstream use
const res = NextResponse.next();
res.headers.set('x-ip-risk', JSON.stringify({
score: risk.risk_score,
proxy: risk.is_proxy,
datacenter: risk.is_datacenter,
vpn: risk.is_vpn,
country: risk.country,
}));
return res;
}
// ... isPrivateIP and config same as beforeWith a 5-minute TTL and 10K entry limit, repeat visitors get instant risk evaluation from cache. For a site with 100K daily visitors, you'll use roughly 20-30K IPASIS lookups instead of 100K+ — a 70-80% reduction in API calls.
Step 3: Route-Specific Protection Levels
Not every route needs the same level of protection. Your homepage can be lenient; your signup endpoint should be strict. Here's how to implement tiered protection:
middleware.ts — route-specific risk thresholds
// Define protection tiers
type ProtectionLevel = 'strict' | 'moderate' | 'light';
const ROUTE_PROTECTION: Record<string, ProtectionLevel> = {
'/api/auth/signup': 'strict',
'/api/auth/login': 'strict',
'/api/checkout': 'strict',
'/api/payments': 'strict',
'/signup': 'strict',
'/login': 'moderate',
'/api/': 'moderate', // Default for all API routes
'/': 'light', // Default for all pages
};
const THRESHOLDS: Record<ProtectionLevel, {
blockScore: number;
challengeScore: number;
blockDatacenter: boolean;
blockResidentialProxy: boolean;
}> = {
strict: {
blockScore: 75,
challengeScore: 50,
blockDatacenter: true,
blockResidentialProxy: true,
},
moderate: {
blockScore: 85,
challengeScore: 65,
blockDatacenter: true,
blockResidentialProxy: false,
},
light: {
blockScore: 95,
challengeScore: 80,
blockDatacenter: false,
blockResidentialProxy: false,
},
};
function getProtectionLevel(pathname: string): ProtectionLevel {
// Check exact matches first
for (const [route, level] of Object.entries(ROUTE_PROTECTION)) {
if (pathname === route) return level;
}
// Check prefix matches
for (const [route, level] of Object.entries(ROUTE_PROTECTION)) {
if (route.endsWith('/') && pathname.startsWith(route)) return level;
}
return 'light';
}
export async function middleware(request: NextRequest) {
const ip = getClientIP(request);
if (isPrivateIP(ip)) return NextResponse.next();
const risk = await getIPRisk(ip);
if (!risk) return NextResponse.next(); // Fail open
const level = getProtectionLevel(request.nextUrl.pathname);
const threshold = THRESHOLDS[level];
// Hard block
if (risk.risk_score > threshold.blockScore) {
return new NextResponse(
JSON.stringify({ error: 'Access denied' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
// Block datacenter IPs on strict routes
if (threshold.blockDatacenter && risk.is_datacenter
&& risk.risk_score > threshold.challengeScore) {
return new NextResponse(
JSON.stringify({
error: 'Please disable VPN/proxy to continue',
code: 'VPN_DETECTED'
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
// Block residential proxies on strict routes
if (threshold.blockResidentialProxy && risk.is_residential_proxy) {
return new NextResponse(
JSON.stringify({
error: 'Suspicious network detected',
code: 'PROXY_DETECTED'
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
// Challenge zone — pass flag to application
const res = NextResponse.next();
if (risk.risk_score > threshold.challengeScore) {
res.headers.set('x-require-challenge', 'true');
}
res.headers.set('x-ip-risk', JSON.stringify({
score: risk.risk_score,
proxy: risk.is_proxy,
datacenter: risk.is_datacenter,
country: risk.country,
}));
return res;
}Now your signup and checkout pages block datacenter IPs and residential proxies with a score above 75, while your public pages only block the most obviously malicious traffic (score > 95). This dramatically reduces false positives while maintaining strong protection where it matters.
Step 4: Using Risk Data in Server Components
The middleware passes risk data via headers. Here's how to access it in your Next.js Server Components:
app/signup/page.tsx — risk-aware signup page
import { headers } from 'next/headers';
interface IPRisk {
score: number;
proxy: boolean;
datacenter: boolean;
country: string;
}
function getIPRisk(): IPRisk | null {
const headerStore = headers();
const riskHeader = headerStore.get('x-ip-risk');
if (!riskHeader) return null;
try {
return JSON.parse(riskHeader);
} catch {
return null;
}
}
export default function SignupPage() {
const risk = getIPRisk();
const requireChallenge = headers().get('x-require-challenge') === 'true';
return (
<div>
<h1>Create Account</h1>
<SignupForm
// Show CAPTCHA for suspicious traffic
showCaptcha={requireChallenge}
// Pre-fill country from IP data
defaultCountry={risk?.country}
/>
</div>
);
}Step 5: API Route Protection
For API routes, you can access the enriched headers and implement route-specific logic:
app/api/auth/signup/route.ts — protected signup API
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
// Risk data was set by middleware
const riskHeader = request.headers.get('x-ip-risk');
const risk = riskHeader ? JSON.parse(riskHeader) : null;
const body = await request.json();
const { email, password, captchaToken } = body;
// If middleware flagged this as needing a challenge
if (request.headers.get('x-require-challenge') === 'true') {
if (!captchaToken) {
return NextResponse.json(
{ error: 'CAPTCHA required', code: 'CAPTCHA_REQUIRED' },
{ status: 400 }
);
}
// Verify CAPTCHA token...
const captchaValid = await verifyCaptcha(captchaToken);
if (!captchaValid) {
return NextResponse.json(
{ error: 'Invalid CAPTCHA', code: 'CAPTCHA_INVALID' },
{ status: 400 }
);
}
}
// Additional email risk check for signups
const emailRisk = await fetch(
`https://api.ipasis.com/v1/email?email=${email}`,
{ headers: { 'Authorization': `Bearer ${process.env.IPASIS_API_KEY}` } }
).then(r => r.json()).catch(() => null);
if (emailRisk?.is_disposable) {
return NextResponse.json(
{ error: 'Please use a valid email address' },
{ status: 400 }
);
}
// Create account with risk metadata
const user = await createUser({
email,
password,
metadata: {
signup_ip_risk: risk?.score,
signup_ip_proxy: risk?.proxy,
signup_ip_country: risk?.country,
signup_email_disposable: emailRisk?.is_disposable,
}
});
return NextResponse.json({ success: true, userId: user.id });
}Step 6: Dynamic Rate Limiting by Risk Score
One powerful pattern: use IP risk scores to set dynamic rate limits. Clean residential IPs get generous limits; suspicious datacenter IPs get throttled:
lib/rate-limit.ts — risk-based rate limiting
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
function getRateLimit(riskScore: number): { max: number; windowMs: number } {
if (riskScore > 80) return { max: 5, windowMs: 60_000 }; // 5/min
if (riskScore > 60) return { max: 20, windowMs: 60_000 }; // 20/min
if (riskScore > 40) return { max: 60, windowMs: 60_000 }; // 60/min
return { max: 120, windowMs: 60_000 }; // 120/min
}
export function checkRateLimit(ip: string, riskScore: number): {
allowed: boolean;
remaining: number;
resetAt: number;
} {
const { max, windowMs } = getRateLimit(riskScore);
const now = Date.now();
const bucket = rateLimitBuckets.get(ip);
if (!bucket || now > bucket.resetAt) {
rateLimitBuckets.set(ip, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: max - 1, resetAt: now + windowMs };
}
bucket.count++;
const remaining = Math.max(0, max - bucket.count);
return {
allowed: bucket.count <= max,
remaining,
resetAt: bucket.resetAt,
};
}Using in middleware
// In your middleware, after getting risk data:
const rateCheck = checkRateLimit(ip, risk.risk_score);
if (!rateCheck.allowed) {
return new NextResponse(
JSON.stringify({ error: 'Rate limited', retryAfter: rateCheck.resetAt }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((rateCheck.resetAt - Date.now()) / 1000)),
'X-RateLimit-Remaining': String(rateCheck.remaining),
}
}
);
}A clean residential IP from the US gets 120 requests/minute. A datacenter IP from a known proxy network with a risk score of 85 gets 5 requests/minute. Same endpoint, dynamically adjusted protection.
Step 7: Graceful Degradation
Your bot detection should never become a single point of failure. Here's how to handle IPASIS API outages gracefully:
lib/ipasis-client.ts — resilient API client
let consecutiveFailures = 0;
let circuitOpenUntil = 0;
const CIRCUIT_THRESHOLD = 5; // Open circuit after 5 failures
const CIRCUIT_RESET_MS = 30_000; // Try again after 30 seconds
export async function lookupIPRisk(ip: string) {
// Check cache first
const cached = getCachedRisk(ip);
if (cached) return cached;
// Circuit breaker — skip API calls if it's been failing
if (Date.now() < circuitOpenUntil) {
return null; // Fail open
}
try {
const res = await fetch(
`https://api.ipasis.com/v1/lookup?ip=${ip}`,
{
headers: { 'Authorization': `Bearer ${process.env.IPASIS_API_KEY}` },
signal: AbortSignal.timeout(2000), // Tight timeout for middleware
}
);
if (!res.ok) throw new Error(`API returned ${res.status}`);
const data = await res.json();
consecutiveFailures = 0; // Reset on success
setCachedRisk(ip, data);
return data;
} catch (error) {
consecutiveFailures++;
if (consecutiveFailures >= CIRCUIT_THRESHOLD) {
circuitOpenUntil = Date.now() + CIRCUIT_RESET_MS;
console.warn('IPASIS circuit breaker OPEN — failing open for 30s');
}
return null; // Fail open
}
}This circuit breaker pattern means: if IPASIS has 5 consecutive failures, stop calling it for 30 seconds to avoid cascading latency in your middleware. During that window, all requests pass through unprotected — which is better than blocking all your users.
Step 8: Monitoring and Observability
Add structured logging to track your bot detection effectiveness:
Logging in middleware
// After risk evaluation in middleware
if (risk) {
// Log for analytics (send to your logging service)
console.log(JSON.stringify({
event: 'ip_risk_check',
ip: ip.slice(0, -3) + 'xxx', // Partial IP for privacy
path: request.nextUrl.pathname,
risk_score: risk.risk_score,
is_proxy: risk.is_proxy,
is_datacenter: risk.is_datacenter,
action: risk.risk_score > threshold.blockScore ? 'blocked'
: risk.risk_score > threshold.challengeScore ? 'challenged'
: 'allowed',
cached: getCachedRisk(ip) !== null,
protection_level: level,
timestamp: new Date().toISOString(),
}));
}Track these metrics over time to understand your traffic quality: What percentage of signups come from proxies? How many API requests come from datacenters? Are your thresholds too aggressive (high false positive rate) or too lenient (fraud getting through)?
Complete Production Example
Here's the full middleware bringing everything together:
middleware.ts — production-ready
import { NextRequest, NextResponse } from 'next/server';
// --- Configuration ---
const IPASIS_KEY = process.env.IPASIS_API_KEY!;
// --- Cache ---
const cache = new Map<string, { data: any; exp: number }>();
const CACHE_TTL = 5 * 60 * 1000;
const CACHE_MAX = 10_000;
function getCache(ip: string) {
const e = cache.get(ip);
if (!e || Date.now() > e.exp) { cache.delete(ip); return null; }
return e.data;
}
function setCache(ip: string, data: any) {
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value!);
cache.set(ip, { data, exp: Date.now() + CACHE_TTL });
}
// --- Circuit Breaker ---
let failures = 0, circuitOpen = 0;
async function lookupIP(ip: string) {
const cached = getCache(ip);
if (cached) return cached;
if (Date.now() < circuitOpen) return null;
try {
const r = await fetch(
`https://api.ipasis.com/v1/lookup?ip=${ip}`,
{
headers: { Authorization: `Bearer ${IPASIS_KEY}` },
signal: AbortSignal.timeout(2000),
}
);
if (!r.ok) throw new Error(`${r.status}`);
const d = await r.json();
failures = 0;
setCache(ip, d);
return d;
} catch {
if (++failures >= 5) circuitOpen = Date.now() + 30_000;
return null;
}
}
// --- Protection Tiers ---
type Tier = 'strict' | 'moderate' | 'light';
const TIERS: Record<Tier, { block: number; challenge: number; dcBlock: boolean }> = {
strict: { block: 75, challenge: 50, dcBlock: true },
moderate: { block: 85, challenge: 65, dcBlock: true },
light: { block: 95, challenge: 80, dcBlock: false },
};
function getTier(path: string): Tier {
if (/^\/api\/auth|signup|login|checkout/.test(path)) return 'strict';
if (path.startsWith('/api/')) return 'moderate';
return 'light';
}
// --- Middleware ---
export async function middleware(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| req.headers.get('x-real-ip') || '127.0.0.1';
if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2|3[01])\.|::1)/.test(ip))
return NextResponse.next();
const risk = await lookupIP(ip);
if (!risk) return NextResponse.next();
const tier = getTier(req.nextUrl.pathname);
const t = TIERS[tier];
if (risk.risk_score > t.block)
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
if (t.dcBlock && risk.is_datacenter && risk.risk_score > t.challenge)
return NextResponse.json(
{ error: 'Please disable VPN/proxy', code: 'VPN_DETECTED' },
{ status: 403 }
);
const res = NextResponse.next();
res.headers.set('x-ip-risk', JSON.stringify({
score: risk.risk_score,
proxy: risk.is_proxy,
datacenter: risk.is_datacenter,
country: risk.country,
}));
if (risk.risk_score > t.challenge)
res.headers.set('x-require-challenge', 'true');
return res;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|public/).*)'],
};Deployment Considerations
Vercel Edge
Next.js middleware runs on the Edge Runtime by default on Vercel. The in-memory cache resets on each cold start, but Vercel's edge functions stay warm for frequently-visited sites. For high-traffic sites, consider using Vercel KV or Upstash Redis for persistent caching.
Self-Hosted (Node.js)
When self-hosting with next start, the in-memory cache persists as long as the Node.js process is running. This is actually ideal — no external cache dependency needed. For multi-instance deployments behind a load balancer, each instance maintains its own cache.
Docker / Kubernetes
In containerized deployments, each pod gets its own cache instance. This is fine — the slight cache duplication across pods is negligible compared to the latency savings. For shared caching, add Redis or Memcached.
Performance Impact
With caching, the performance overhead is minimal:
For most sites, the effective added latency averages under 10ms across all requests. That's imperceptible to users while blocking the vast majority of bot traffic.
Common Patterns
Allow-list Trusted IPs
// Skip risk check for known-good IPs (monitoring, webhooks, etc.) const TRUSTED_IPS = new Set([ '52.14.15.16', // Your CI/CD '34.234.56.78', // Stripe webhooks '192.168.0.0/16', // Internal ]); if (TRUSTED_IPS.has(ip)) return NextResponse.next();
Geo-Based Rules
// Stricter thresholds for countries with high fraud rates
const HIGH_RISK_COUNTRIES = new Set(['NG', 'PH', 'VN', 'PK']);
if (HIGH_RISK_COUNTRIES.has(risk.country)) {
// Lower threshold for these regions
if (risk.risk_score > 60) {
return NextResponse.json(
{ error: 'Additional verification required', code: 'GEO_CHALLENGE' },
{ status: 403 }
);
}
}Webhook Verification
// Skip bot detection for verified webhook sources
if (request.nextUrl.pathname.startsWith('/api/webhooks/')) {
// Verify webhook signature instead of IP risk
return NextResponse.next();
}Next Steps
- Get your free API key — 1,000 lookups/day, no credit card
- Copy the basic middleware from Step 1 and test with your dev server
- Add caching and tiered protection once you're comfortable with the basics
- Monitor your logs — adjust thresholds based on real traffic patterns
- Scale up — upgrade your plan as traffic grows
Need help? Check our API documentation or reach out to our team.