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:
- Tracking ad clicks per visitor
- Blocking ad display when clicks exceed a threshold within a time window
- 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.
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.
- Attach a
mouseenterlistener to the ad container to track when the mouse is over the ad - Listen for the
windowblurevent — when a user clicks inside an iframe, focus moves to the iframe, triggeringbluron the parent window - If
blurfires while the mouse is over the ad container, count it as an ad click
// 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
npm install @upstash/redis
Add your Upstash credentials to .env.local:
UPSTASH_REDIS_REST_URL=https://your-instance.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token-here
API Route Implementation
// 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.
// 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.
// 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;
// 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:
// Usage
<ProtectedAdSlot slotId="1234567890" />
Alerting via Discord (Optional)
When a visitor gets blocked, a Discord notification lets you monitor the situation in real time.
// 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.
- Server Settings → Integrations → Webhooks
- Create a new webhook, copy the URL
- 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
AdClickGuardis 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:
- Click detection — blur + mouseenter pattern
- Counting — Upstash Redis with per-IP tracking
- Blocking — Hide ads when the threshold is exceeded
- 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.