RQ4

Bot detection middleware for Next.js App Router

June 15, 2026

Bot detection in Next.js belongs in middleware. The middleware runs at the edge before any route handler, has direct access to request headers and IP, and can short-circuit with a 403 response when a request fails detection. This post covers three concrete patterns: calling an external fraud-check API, inline header analysis with zero added latency, and a deferred-verdict pattern designed for AI-SaaS signup flows where adding latency to the user's signup is unacceptable.

Code samples target Next.js 15.x App Router on the Edge Runtime. They translate cleanly to Next.js 14 and the legacy Pages Router with minor adjustments noted at the end.

What this looks like in practice

Common scenario: an indie founder ships a Next.js AI-image-generator on Vercel. Free trial gives every signup 10 generations. Day 1 of launch: 5 real signups, organic from a small post on a maker community. Day 3: 50 signups overnight, OpenAI bill $80 for the day. Day 4: 200 signups, $300 bill. The founder checks Vercel Analytics, none of the bot signups converted to a paid plan. None of them generated a real-looking image; they all hit the API exactly the maximum-credits-allowed number of times then disappeared.

Three options on the table:

  1. Disable the free trial entirely. Cuts the bleeding but destroys signup conversion for real users.
  2. Require a credit card at signup. Same downside, even worse, adds friction at the worst point in the funnel.
  3. Detect and block bots before they consume credits. Requires implementation but doesn't penalize real users.

The founder picks option 3. Googles "Next.js bot detection middleware." Reads two tutorials, settles on Pattern A from this article (external fraud-check API call). Adds 15 lines to middleware.ts. The same code shown below. Sets up a fraud-detection API account, gets a key, deploys.

Next 24 hours: real signups continue at the same rate. Bot signup attempts continue too: they hit the middleware, get checked against the API, score block, return 403, never reach the signup handler. OpenAI bill returns to normal. The founder ships a Twitter post about it ("don't launch your AI tool without bot detection") that pulls in a couple hundred new signups, with the middleware now silently blocking the bots in the inbound traffic.

The middleware-layer integration is the only thing that lets the founder leave the free trial intact. Subsequent product decisions (pricing tier, credit allocation, paid plan onboarding) get to happen on real signal instead of an inflated user count.

What Next.js middleware can and can't do

Middleware in Next.js (middleware.ts at the project root) runs on every request matching its matcher config. It runs on the Edge Runtime, a constrained V8 environment without Node-specific APIs. You get fetch, crypto, URL, Request/Response, and the standard Web APIs. You don't get fs, child_process, or Node-only npm packages.

The middleware sees the request before it reaches any route handler. It receives a NextRequest (extends Request with nextUrl, geo, ip), can read all headers including Sec-Fetch-*, User-Agent, Accept-Encoding, and forwarded IP (x-forwarded-for or cf-connecting-ip depending on your hosting). It can return any Response directly, rewrite to a different URL, or call NextResponse.next() to let the request proceed.

What it cannot see: the visitor's TLS handshake. Next.js typically sits behind a CDN (Vercel, Cloudflare, AWS CloudFront) which terminates TLS before the request reaches your middleware. That means TLS-fingerprint-based bot detection has to happen at the CDN layer, not in your Next.js code. Header-based and IP-based detection works fine.

There's a 50ms compute time budget on Vercel's edge by default for synchronous middleware logic. Anything that exceeds it gets killed. External fetch calls don't count against that 50ms but do add wall-clock latency to the request, keep the timeout tight.

Pattern A: call an external fraud-check API

The most flexible pattern. Middleware POSTs the visitor's headers and IP to an external detection API, inspects the verdict, and returns 403 on block. This works with any fraud API that accepts JSON: FingerprintJS Pro's server-side API, IPQualityScore's REST API, your own internal detection service.

// app/middleware.ts
import { type NextRequest, NextResponse } from "next/server";

const FRAUD_API_KEY = process.env.FRAUD_API_KEY!;
const HIGH_VALUE_PATHS = ["/api/signup", "/api/login", "/api/checkout"];

export async function middleware(request: NextRequest) {
  const path = request.nextUrl.pathname;
  if (!HIGH_VALUE_PATHS.some(p => path.startsWith(p))) {
    return NextResponse.next();
  }

  const ip = request.headers.get("x-forwarded-for")?.split(",")[0].trim()
    ?? request.headers.get("cf-connecting-ip") ?? "";

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 500);

  try {
    const res = await fetch("https://api.your-fraud-vendor.example/v1/check", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${FRAUD_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        ip,
        headers: Object.fromEntries(request.headers.entries()),
        action: path.split("/").pop(),
      }),
      signal: controller.signal,
    });
    clearTimeout(timeoutId);
    const verdict = await res.json();
    if (verdict.action === "block") {
      return NextResponse.json({ error: "request_blocked" }, { status: 403 });
    }
  } catch {
    // Fail open on timeout/network error, never block real users when the API
    // is slow or unreachable.
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/api/signup", "/api/login", "/api/checkout"],
};

The matcher config restricts the middleware to only the routes that need it. Running fraud detection on every static asset request is wasteful and adds latency to image loads. Target it narrowly.

The 500ms AbortController timeout is intentionally tight. Real fraud APIs return verdicts in 40–100ms p50; if yours doesn't, your real users will notice. Fail open on timeout, a few seconds of degraded detection during an API outage is better than blocking every legitimate signup.

Pattern B: inline header analysis with zero added latency

For non-critical routes or as a first line of defense, analyze headers in the middleware itself. No external call, no latency cost, but the detection ceiling is lower. You catch obvious library traffic (curl, python-requests, scrapy) but miss anything more sophisticated.

// app/middleware.ts
import { type NextRequest, NextResponse } from "next/server";

const LIBRARY_UA_PATTERNS = [
  /^python-(requests|httpx|urllib)/i, /^go-http-client/i, /^scrapy/i,
  /^curl/i, /^wget/i, /^okhttp/i, /^axios/i, /^node(-fetch)?/i, /^java/i,
];

export function middleware(request: NextRequest) {
  const ua = request.headers.get("user-agent") ?? "";

  // Library UA, not a browser
  if (LIBRARY_UA_PATTERNS.some(re => re.test(ua))) {
    return NextResponse.json({ error: "automation_detected" }, { status: 403 });
  }

  // Impossible-state header: navigate + POST + site:none
  if (request.method === "POST"
      && request.headers.get("sec-fetch-mode") === "navigate"
      && request.headers.get("sec-fetch-site") === "none") {
    return NextResponse.json({ error: "automation_detected" }, { status: 403 });
  }

  // Chrome UA without Sec-Fetch headers, Chrome always sends them
  if (ua.includes("Chrome/")
      && !request.headers.get("sec-fetch-mode")
      && !request.headers.get("sec-fetch-site")) {
    return NextResponse.json({ error: "automation_detected" }, { status: 403 });
  }

  return NextResponse.next();
}

export const config = { matcher: ["/api/signup", "/api/login"] };

The three checks above catch most commodity bots without external dependencies. The impossible-state navigate-POST check is derived from the Fetch specification, Sec-Fetch-Site: none indicates "user typed URL in address bar" which is always a GET, never a POST. A library that copies navigation headers onto a POST request trips this regardless of how convincing its User-Agent is.

What this won't catch: a bot using real headless Chrome with proper Sec-Fetch headers per request type. For that you need TLS-layer detection (which Next.js middleware can't do, the CDN is upstream) or a browser-side fingerprint snippet plus an external API.

Pattern C: deferred verdict for AI-SaaS signup flows

The AI-credit-burn attack pattern hits AI SaaS apps that grant trial credits to new signups. The bot signs up 200 accounts overnight, burns the credits, disappears. The owner sees the bill the next morning.

Pattern A blocks the signup outright at 403, which is the right response if your verdict has high confidence. But for an indie AI SaaS where signup conversion is precious, false positives hurt. A better pattern: let the signup complete, but hold credit-granting until the verdict comes in. The user sees no added latency on signup; the bot doesn't get credits because credits are gated on a verdict the bot's request will never pass.

// app/middleware.ts
import { type NextRequest, NextResponse } from "next/server";

const FRAUD_API_KEY = process.env.FRAUD_API_KEY!;

export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname !== "/api/signup") return NextResponse.next();

  const requestId = crypto.randomUUID();
  const ip = request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "";

  // Fire and forget, verdict written to KV/Redis keyed by request ID.
  fetch("https://api.your-fraud-vendor.example/v1/check", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${FRAUD_API_KEY}`,
      "Content-Type": "application/json",
      "X-Request-Id": requestId,
    },
    body: JSON.stringify({
      ip,
      headers: Object.fromEntries(request.headers.entries()),
      action: "signup",
    }),
  }).catch(() => {});

  const response = NextResponse.next();
  response.headers.set("x-fraud-request-id", requestId);
  return response;
}

export const config = { matcher: ["/api/signup"] };

Your signup API route reads x-fraud-request-id, creates the account in a pending state, and queues a background job. The job polls the verdict (or subscribes to a callback). On clean verdict, grant credits. On suspicious or block, flag for review or silently never grant credits. The user gets a normal signup confirmation; the bot signs up successfully but gets zero credits, defeating the attack economics.

This requires more plumbing than Pattern A, a KV store or DB for request-id-to-verdict mapping, a background job runner, but the user experience is uncompromised.

Pages Router note

For Next.js projects still on the Pages Router (pages/ directory), middleware at the project root works the same way. The import is import { type NextRequest, NextResponse } from "next/server", identical. The matcher config is identical. The Edge Runtime constraints are identical. The only routine difference: API routes in Pages Router are under pages/api/, so adjust your matcher paths accordingly.

Limitations

Common questions

Will Next.js middleware slow down my entire site?

Only the routes you list in the matcher config. The default matcher matches every route. If you leave it as is, the middleware runs on every page load, every image, every API call, every static asset request. That's almost always wrong for bot detection. Set the matcher narrowly to only the high-value routes (signup, login, checkout, redeem-credits). For everything else, middleware doesn't run at all.

I'm on Pages Router, not App Router, do I have to migrate?

No. The middleware code in this article works identically on Pages Router. The file location and import path are the same: middleware.ts at the project root, import { type NextRequest, NextResponse } from "next/server". The only adjustment is that your API routes live under pages/api/ instead of app/api/, so your matcher paths reflect that directory structure.

What happens to my real users if the fraud-check API goes down?

Depends on whether you fail open or fail closed. The Pattern A code in this article fails open: if the API times out or errors, the request proceeds normally. That's the right default for most flows: better to lose a few hours of detection during an outage than to block every real user during the same outage. For high-fraud endpoints (e.g., financial-redemption flows) you can swap to fail-closed, but be aware this couples your uptime to the detection API's uptime. Most products keep fail-open and accept the tradeoff.

Should the API key be in NEXT_PUBLIC_* env or just regular env?

Regular env. The NEXT_PUBLIC_* prefix exposes the variable to the browser bundle, anyone viewing your site's JavaScript source sees the value. A fraud-detection API key in NEXT_PUBLIC_* would let any visitor abuse your key budget. The middleware runs server-side (Edge Runtime, technically), so it reads from regular env via process.env.FRAUD_API_KEY without the NEXT_PUBLIC_ prefix. The key never leaves the server.

How do I test middleware locally before deploying to Vercel?

Run next dev and hit your routes locally. The middleware runs on Next.js's local Edge Runtime simulator. The fraud-detection API call goes out to the real API (use your dev/staging key). You can also unit-test the middleware's logic by importing it and calling it with a fake NextRequest in a Jest/Vitest test, works for Pattern B (inline analysis) trivially, requires mocking fetch for Pattern A.

Can I run this on Edge Functions, or do I need the full Node runtime?

Edge Runtime is what middleware uses by default. That's the right one. Edge Functions are also fine for the same use case if you'd rather put detection logic in a route handler than middleware. You don't need the Node runtime; the operations in this article (fetch, regex, header reads) all work in the Edge Runtime's V8 environment. If you need Node-specific APIs (fs, child_process, certain npm packages), you'd switch, but bot detection rarely needs them.

Related reading

Stay updated

We publish on request fingerprinting, browser bot detection, and running it in production. Drop your email for new posts.