GUIDE

AI Visibility

Dupes shows which AI assistants and crawlers read your site, grouped by what they came for. This is how you see whether your content is fueling AI answers, search indexes, or model training.

💬
AI Answers
User-triggered fetches when an assistant opens your page to answer someone — ChatGPT-User, Claude-User, Perplexity-User, Copilot…
🔎
Indexing
Discovery crawlers that keep AI search indexes fresh — Googlebot, OAI-SearchBot, PerplexityBot, Bingbot…
🧠
Training
Crawlers collecting public content to train models — GPTBot, ClaudeBot, CCBot, Bytespider…
🤖
Other bots
Non-human traffic that isn't an AI crawler — SEO suites, uptime monitors, link previewers. Tracked so it can be kept out of your visitor counts.

Why this needs a second snippet

AI crawlers don't run JavaScript. They request your HTML and leave, so script.js never executes and the normal tracker never sees them. The only place an AI crawler is visible is the server that answered it — which for your site is your infrastructure, not ours. So AI Visibility needs a few lines in your request path to report what came through.

Next.js

One helper file, then one line in your proxy.

// lib/dupes-ai-crawl.ts — drop this file into YOUR app.
import type { NextFetchEvent, NextRequest } from "next/server";

const ENDPOINT = "https://dupes-gilt.vercel.app//api/collect/server";

// Loose pre-filter so we don't POST on every human request. Dupes
// re-classifies server-side against the full crawler directory, so this only
// has to be wide enough not to miss anything.
const LIKELY_BOT = /bot|crawler|spider|GPT|Claude|Perplexity|OAI-|ChatGPT|CCBot|Bytespider/i;

export function trackAICrawlerRequest(
  request: NextRequest,
  event: NextFetchEvent,
  opts: { websiteId: string; token?: string },
) {
  const ua = request.headers.get("user-agent") || "";
  if (!LIKELY_BOT.test(ua)) return;

  // event.waitUntil — NOT a bare fetch. The runtime may tear the invocation
  // down as soon as you return a response, which would kill the report
  // mid-flight. waitUntil extends the lifetime until it settles; it still
  // never delays your response.
  event.waitUntil(
    fetch(ENDPOINT, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
      },
      body: JSON.stringify({
        websiteId: opts.websiteId,
        host: request.nextUrl.hostname,
        pathname: request.nextUrl.pathname, // path only — no query strings
        userAgent: ua,
        // Lets Dupes confirm the crawler is who it claims to be.
        ip:
          request.headers.get("x-real-ip") ??
          request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
          null,
      }),
    }).catch(() => {}),
  );
}
// proxy.ts (Next.js 16) or middleware.ts (Next.js 15 and below).
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";
import { trackAICrawlerRequest } from "@/lib/dupes-ai-crawl";

// Note the second argument — you need it for waitUntil.
export function proxy(request: NextRequest, event: NextFetchEvent) {
  trackAICrawlerRequest(request, event, { websiteId: "dps_your_site_id" });

  // Crawler-facing files must pass straight through. If your app rewrites by
  // locale, /robots.txt would otherwise become /pt/robots.txt and break.
  const path = request.nextUrl.pathname;
  if (path === "/robots.txt" || path === "/sitemap.xml" || path === "/llms.txt") {
    return NextResponse.next();
  }

  // …your existing logic…
  return NextResponse.next();
}

export const config = {
  // Keep robots.txt / sitemap.xml / llms.txt IN the matcher — crawler hits on
  // those are the ones worth seeing.
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Express & friends

// Express / Node — same idea, any framework with a request hook.
app.use((req, res, next) => {
  const ua = req.get("user-agent") || "";
  if (/bot|crawler|spider|GPT|Claude|Perplexity|OAI-|ChatGPT|CCBot/i.test(ua)) {
    res.on("finish", () => {
      fetch("https://dupes-gilt.vercel.app//api/collect/server", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          websiteId: "dps_your_site_id",
          host: req.hostname,
          pathname: req.path,
          userAgent: ua,
          ip: req.ip,
          statusCode: res.statusCode, // 404s on cited URLs are worth knowing
        }),
      }).catch(() => {});
    });
  }
  next();
});

Any other backend

Post the same JSON from PHP, Python, Ruby, Go — anything that can make an HTTP request.

POST https://dupes-gilt.vercel.app//api/collect/server
Content-Type: application/json

{
  "websiteId":  "dps_your_site_id",
  "host":       "example.com",
  "pathname":   "/pricing",
  "userAgent":  "Mozilla/5.0 (compatible; GPTBot/1.1; +https://openai.com/gptbot)",
  "ip":         "203.0.113.10",
  "statusCode": 200
}

Notes

  • Never await the call. Fire it and return your response — a crawler report must not sit in front of your page.
  • Send the path, not the URL. Query strings can carry tokens. Dupes strips anything after ? anyway, but don't send it in the first place.
  • Send the IP when you have it. A User-Agent is free to forge, and scrapers routinely claim to be GPTBot. When the IP reverse-resolves to the vendor it claims, the crawler gets a ✓ in your dashboard.
  • Send the status code when you can. An AI citing a URL that 404s is the most actionable thing this report can tell you.
  • Non-bot traffic is ignored with a 204, so it's safe to call on every request.

Verification

A User-Agent is a string anyone can send, so a hit claiming to be GPTBot proves nothing on its own. Where a vendor publishes a way to check, Dupes checks — and only then shows the ✓.

  • Reverse DNS— Google, Microsoft, Apple, DuckDuckGo, Yandex, Baidu. The source IP must resolve to a hostname at the vendor's domain, and that hostname must resolve back to the same IP.
  • Published IP ranges — OpenAI and Perplexity ship the CIDR blocks their crawlers use (openai.com/gptbot.json and friends). Dupes keeps a cached copy and tests the source IP against it. The check is per crawler, not per vendor: a GPTBot hit has to appear in gptbot.json, not merely somewhere in OpenAI's address space.
  • Neither — Anthropic publishes no machine-readable feed for ClaudeBot, so those hits are recorded and shown but never ticked.

An unticked crawler means “we couldn't confirm this”, never “this is fake”. Send the ip field and you get the distinction; omit it and nothing can be verified at all.