32blogby StudioMitsu
nextjs8 min read

Build an AdSense Click Fraud Shield in Next.js

Build an AICP-equivalent AdSense click fraud protection system in Next.js. Covers blur+mouseenter detection, Upstash Redis tracking, and Discord alerting.

Next.jsAdSensesecurityclick-fraud-protection
On this page

After migrating my blog from WordPress to Next.js, I noticed something missing: AICP (AdSense Invalid Click Protector).

On WordPress, you install the AICP plugin and you're covered. It detects fraudulent clicks and hides ads from suspicious visitors. But in the Next.js ecosystem? Nothing like it exists.

This article walks through building an AICP-equivalent protection system using Next.js App Router. We'll cover click detection, API-based tracking with Upstash Redis, automatic ad hiding, and optional Discord alerts.

What Is AdSense Click Fraud?

AdSense click fraud — sometimes called "click bombing" — is when someone repeatedly clicks your ads to trigger Google's invalid click detection. The irony: Google penalizes the publisher, not the attacker. Your AdSense account gets restricted or suspended.

In the WordPress world, AICP (AdSense Invalid Click Protector) became the standard defense. It works by:

  1. Tracking ad clicks per visitor
  2. Blocking ad display when clicks exceed a threshold within a time window
  3. Identifying visitors by cookie and IP

When I moved from WordPress to Next.js, I searched npm for an equivalent package. Nothing. No Next.js-specific AdSense protection library exists.

So I built one.

Architecture Overview

The system has four components.

mermaid
graph TD
  A[User clicks an ad] --> B[AdClickGuard: blur+mouseenter detection]
  B --> C[POST /api/ad-guard]
  C --> D[Upstash Redis: per-IP counter]
  D --> E{Limit exceeded?}
  E -->|No| F[Continue showing ads]
  E -->|Yes| G[Hide ads from this visitor]
  E -->|Yes| H[Discord notification]
  • AdClickGuard — Client-side component that detects ad clicks
  • API Route — Records click counts and determines if a visitor is blocked
  • Upstash Redis — Stores per-IP click counts with automatic expiration
  • Discord notification — Alerts you when suspicious activity is detected (optional)

Under the hood, AICP does the same thing. We're replacing WordPress hooks with React components and API Routes.

Detecting Ad Clicks

Detecting ad clicks has a fundamental challenge: AdSense ads are served inside iframes, and the Same-Origin Policy prevents the parent page from accessing click events inside cross-origin iframes.

The workaround is the blur + mouseenter pattern.

  1. Attach a mouseenter listener to the ad container to track when the mouse is over the ad
  2. Listen for the window blur event — when a user clicks inside an iframe, focus moves to the iframe, triggering blur on the parent window
  3. If blur fires while the mouse is over the ad container, count it as an ad click
tsx
// components/AdClickGuard.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

const AD_GUARD_ENDPOINT = "/api/ad-guard";

type Props = {
  children: React.ReactNode;
  maxClicks?: number;
};

export function AdClickGuard({ children, maxClicks = 3 }: Props) {
  const containerRef = useRef<HTMLDivElement>(null);
  const isMouseOverAd = useRef(false);
  const [blocked, setBlocked] = useState(false);

  // Check block status on mount
  useEffect(() => {
    fetch(AD_GUARD_ENDPOINT)
      .then((res) => res.json())
      .then((data) => {
        if (data.blocked) setBlocked(true);
      })
      .catch(() => {});
  }, []);

  const reportClick = useCallback(async () => {
    try {
      const res = await fetch(AD_GUARD_ENDPOINT, { method: "POST" });
      const data = await res.json();
      if (data.blocked) {
        setBlocked(true);
      }
    } catch {
      // Ignore network errors
    }
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleMouseEnter = () => {
      isMouseOverAd.current = true;
    };
    const handleMouseLeave = () => {
      isMouseOverAd.current = false;
    };

    const handleBlur = () => {
      if (isMouseOverAd.current) {
        reportClick();
        // Refocus to detect subsequent clicks
        setTimeout(() => window.focus(), 0);
      }
    };

    container.addEventListener("mouseenter", handleMouseEnter);
    container.addEventListener("mouseleave", handleMouseLeave);
    window.addEventListener("blur", handleBlur);

    return () => {
      container.removeEventListener("mouseenter", handleMouseEnter);
      container.removeEventListener("mouseleave", handleMouseLeave);
      window.removeEventListener("blur", handleBlur);
    };
  }, [reportClick]);

  if (blocked) return null;

  return <div ref={containerRef}>{children}</div>;
}

The key detail is setTimeout(() => window.focus(), 0). By refocusing the parent window after a blur, we can detect the next click too.

Tracking Clicks with an API Route

We use Upstash Redis for click tracking. It pairs naturally with serverless environments and has near-zero cold start latency.

Setup

bash
npm install @upstash/redis

Add your Upstash credentials to .env.local:

bash
UPSTASH_REDIS_REST_URL=https://your-instance.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token-here

API Route Implementation

ts
// app/api/ad-guard/route.ts
import { Redis } from "@upstash/redis";
import { headers } from "next/headers";
import { NextResponse } from "next/server";

import { AD_GUARD_CONFIG } from "@/lib/ad-guard-config";

const redis = Redis.fromEnv();

function getKey(ip: string): string {
  return `${AD_GUARD_CONFIG.redisKeyPrefix}:${ip}`;
}

async function getClientIp(): Promise<string> {
  const h = await headers();
  return (
    h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
    h.get("x-real-ip") ??
    "unknown"
  );
}

// GET: Check current block status
export async function GET() {
  const ip = await getClientIp();
  const count = (await redis.get<number>(getKey(ip))) ?? 0;

  return NextResponse.json({
    blocked: count >= AD_GUARD_CONFIG.maxClicks,
    count,
  });
}

// POST: Record a click
export async function POST() {
  const ip = await getClientIp();
  const key = getKey(ip);

  const count = await redis.incr(key);

  // Set expiration only on first click (fixed window, not sliding)
  if (count === 1) {
    await redis.expire(key, AD_GUARD_CONFIG.windowSeconds);
  }

  const blocked = count >= AD_GUARD_CONFIG.maxClicks;

  if (blocked) {
    // Discord notification (optional)
    if (process.env.DISCORD_WEBHOOK_URL) {
      const { notifyDiscord } = await import("@/lib/notify-discord");
      await notifyDiscord(ip, count);
    }
  }

  return NextResponse.json({ blocked, count });
}

Running Without Redis Locally

For local development, you can swap Redis for an in-memory Map.

ts
// lib/ad-guard-store-local.ts (development only)
const store = new Map<string, { count: number; expiresAt: number }>();

export function increment(ip: string, windowSeconds: number): number {
  const now = Date.now();
  const existing = store.get(ip);

  if (!existing || existing.expiresAt < now) {
    store.set(ip, { count: 1, expiresAt: now + windowSeconds * 1000 });
    return 1;
  }

  existing.count += 1;
  return existing.count;
}

export function getCount(ip: string): number {
  const existing = store.get(ip);
  if (!existing || existing.expiresAt < Date.now()) return 0;
  return existing.count;
}

Hiding Ads When the Limit Is Exceeded

While you can use AdClickGuard directly, extracting the configuration into a separate file and creating a wrapper component makes it easier to manage.

ts
// lib/ad-guard-config.ts
export const AD_GUARD_CONFIG = {
  /** Maximum clicks allowed within the time window */
  maxClicks: 3,
  /** Time window in seconds */
  windowSeconds: 60 * 60, // 1 hour
  /** Redis key prefix */
  redisKeyPrefix: "ad-guard",
} as const;
tsx
// components/ProtectedAdSlot.tsx
"use client";

import { AdClickGuard } from "./AdClickGuard";
import { AD_GUARD_CONFIG } from "@/lib/ad-guard-config";

type Props = {
  slotId: string;
  format?: string;
  className?: string;
};

export function ProtectedAdSlot({
  slotId,
  format = "auto",
  className,
}: Props) {
  return (
    <AdClickGuard maxClicks={AD_GUARD_CONFIG.maxClicks}>
      <ins
        className={`adsbygoogle ${className ?? ""}`}
        style={{ display: "block" }}
        data-ad-client="ca-pub-XXXXXXXXXXXXXXX"
        data-ad-slot={slotId}
        data-ad-format={format}
        data-full-width-responsive="true"
      />
    </AdClickGuard>
  );
}

Drop it in anywhere you'd place an ad:

tsx
// Usage
<ProtectedAdSlot slotId="1234567890" />

Alerting via Discord (Optional)

When a visitor gets blocked, a Discord notification lets you monitor the situation in real time.

ts
// lib/notify-discord.ts
export async function notifyDiscord(
  ip: string,
  clickCount: number
): Promise<void> {
  const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
  if (!webhookUrl) return;

  const maskedIp = ip.replace(/\.\d+$/, ".***");

  await fetch(webhookUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      embeds: [
        {
          title: "⚠️ AdSense Click Guard Alert",
          color: 0xff6b35,
          fields: [
            { name: "IP", value: maskedIp, inline: true },
            { name: "Clicks", value: String(clickCount), inline: true },
            {
              name: "Action",
              value: "Ads hidden for this visitor",
              inline: true,
            },
          ],
          timestamp: new Date().toISOString(),
        },
      ],
    }),
  });
}

No need to create a Discord bot. Just generate a Webhook URL from your server settings.

  1. Server Settings → Integrations → Webhooks
  2. Create a new webhook, copy the URL
  3. Add DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... to .env.local

Production Considerations

False Positives

The blur + mouseenter pattern isn't perfect. False positives can occur when:

  • A visitor hovers over the ad area while switching tabs
  • Mobile touch events trigger blur unexpectedly
  • Multiple people behind the same IP (corporate NAT, cafe WiFi) legitimately click ads

This is why the threshold matters. Don't block on a single click — use a generous limit like 3 clicks per hour. AICP's defaults follow the same philosophy.

Performance

  • AdClickGuard is pure client-side event listeners. Zero performance impact on rendering
  • The API Route makes a single Upstash REST call. Latency is single-digit milliseconds
  • Redis EXPIRE handles automatic cleanup — no storage maintenance needed

Google AdSense Policy

This system is a defensive measure to protect your account from invalid clicks. It doesn't violate AdSense policies. Google provides an invalid clicks contact form and encourages publishers to take proactive steps.

However, overly aggressive thresholds that hide ads from legitimate visitors will reduce impressions and revenue. Tune your thresholds carefully.

Wrapping Up

What WordPress's AICP plugin does under the hood is straightforward:

  1. Click detection — blur + mouseenter pattern
  2. Counting — Upstash Redis with per-IP tracking
  3. Blocking — Hide ads when the threshold is exceeded
  4. Alerting — Discord webhook notifications

Moving from WordPress to Next.js doesn't mean losing this protection. About 100 lines of code gets you equivalent functionality. It's not a perfect solution, but it's far better than nothing.

Don't wait until you're hit by click fraud. An AdSense account suspension is hard to reverse — set up protection early.