IPASIS - IP Reputation and Risk Intelligence API
Blog/Integration Guide

How to Add Bot Detection to Next.js with Middleware

March 29, 202618 min read
{ }
Next.js + IPASIS

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 before

With 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:

Scenario
Added Latency
Cache hit
< 1ms
Cache miss (API call)
30-50ms
Circuit breaker open
< 1ms (skip)
Cache hit rate (typical)
70-85%

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

  1. Get your free API key — 1,000 lookups/day, no credit card
  2. Copy the basic middleware from Step 1 and test with your dev server
  3. Add caching and tiered protection once you're comfortable with the basics
  4. Monitor your logs — adjust thresholds based on real traffic patterns
  5. Scale up — upgrade your plan as traffic grows

Need help? Check our API documentation or reach out to our team.

Related Resources