DocsNext.js

Docs · Collectors

Next.js collector

Report every request your Next.js app serves from proxy.ts, and named actions with track().

Add the code

Create proxy.ts next to your app folder. Already have one? Add report() and move the body of this proxy function to the top of yours.

proxy.ts
import type { NextFetchEvent, NextRequest } from "next/server";const secret = /^(cookie|authorization|proxy-authorization)$/i;function report(request: NextRequest, event: NextFetchEvent, extra: object = {}) {  // Every other header goes, signature, signature-input and signature-agent included: they prove a signed agent.  const headers = Object.fromEntries([...request.headers].filter(([name]) => !secret.test(name)));  // Set by your host, or the hop your nearest proxy appended last. The first x-forwarded-for entry is the client's word.  const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-real-ip") ?? request.headers.get("x-forwarded-for")?.split(",").at(-1)?.trim();  event.waitUntil(    fetch("https://ethogram.si/api/e", {      method: "POST",      headers: { authorization: `Bearer ${process.env.ETHOGRAM_KEY}`, "content-type": "application/json" },      body: JSON.stringify({ collector: "0.12", events: [{ url: request.url, method: request.method, time: Date.now(), ip, headers, ...extra }] }),    }).catch(() => {}),  );}export async function proxy(request: NextRequest, event: NextFetchEvent) {  const { pathname } = request.nextUrl;  // The optional browser check: its script, and what it reports about how each page was used.  if (pathname === "/_ethogram/check.js") {    const script = await fetch("https://ethogram.si/ethogram.js");    return new Response(script.body, { status: script.status, headers: { "content-type": "text/javascript", "cache-control": "public, max-age=3600" } });  }  if (pathname === "/_ethogram/report") {    const body = await request.text().catch(() => "");    try {      if (body.length <= 4096) report(request, event, { browser: JSON.parse(body) });    } catch {}    return new Response(null, { status: 204 });  }  // Link prefetches aren't visits.  if (!request.headers.has("next-router-prefetch")) report(request, event);}// Pages, forms, API calls and JavaScript files (Ethogram counts the scripts agents fetch); not Next's other files or static assets.export const config = { matcher: ["/((?!_next/(?!static/)|favicon.ico|.*\\.(?:css|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|mp4|webm)$).*)"] };

Set the server key

The code reads your project's server key from the ETHOGRAM_KEY environment variable. Create the key on your project's setup page in the dashboard (or under Settings, Server keys), and set it where your server runs. It's shown once, so save it straight away. Keep it secret, like a password: it lets whoever holds it report events for your site.

On Next.js 15 or earlier, the file is middleware.ts and the function is named middleware.

What it sends

One event per request, sent with event.waitUntil after the proxy has run, so the page never waits on Ethogram and errors are ignored. Each event has:

  • url and method: the request as received.
  • time: when it arrived.
  • ip: cf-connecting-ip, else x-real-ip, else the last x-forwarded-for entry (the hop your nearest proxy added; the first entry is only the client's word).
  • headers: every request header except cookie, authorization and proxy-authorization. That includes user-agent, referer, and signature, signature-input and signature-agent, which prove a signed agent.

A proxy runs before the response, so this collector sends no status, duration or response, and the reasons agents get stuck (a challenge page, a JavaScript-only page) can't be told apart. Use the Cloudflare Worker in front of it if you need them.

What it skips

  • Link prefetches (requests with the next-router-prefetch header): they aren't visits.
  • Next's own files under /_next/ other than /_next/static/, favicon.ico, and static assets such as CSS, images, fonts and video, by the matcher at the bottom of the file.
  • Not JavaScript files: requests for .js and .mjs, your bundles under /_next/static/ included, are reported. Ethogram counts the scripts each agent fetches for its profile, adds them to no request count, and drops people's on arrival. That means the proxy now runs on every /_next/static/*.js request: more middleware invocations on your host, plus one POST to Ethogram per chunk. Proxies installed before October 19, 2026 skip them: update yours to see them.

Optional: named actions

Page views, searches, form posts and cart or checkout requests show up on their own. To see actions by name, report them from the code that does them. Add this file, then call track() wherever your app adds to cart, books or signs someone up. More in Named actions with track().

lib/ethogram.ts
import { after } from "next/server";import { headers } from "next/headers";// In a server action or route handler: await track("Added to cart", product.name)export async function track(name: string, target?: string) {  const h = await headers();  const ip = h.get("cf-connecting-ip") ?? h.get("x-real-ip") ?? h.get("x-forwarded-for")?.split(",").at(-1)?.trim();  // Who did it. Signatures aren't checked on actions (the page's own request carries them), so they needn't go.  const sent = Object.fromEntries(["user-agent", "signature-agent"].flatMap((k) => (h.get(k) ? [[k, h.get(k)]] : [])));  after(() =>    fetch("https://ethogram.si/api/e", {      method: "POST",      headers: { authorization: `Bearer ${process.env.ETHOGRAM_KEY}`, "content-type": "application/json" },      body: JSON.stringify({        collector: "0.12",        events: [{ url: h.get("referer") ?? `https://${h.get("host")}/`, method: "POST", time: Date.now(), ip, headers: sent, action: { name, target } }],      }),    }).catch(() => {}),  );}

Optional: the browser check

Some AI agents drive a real browser, like Claude in Chrome or Comet. To your server, their requests look like a person's. The browser check is a small script that tells them apart by how each page is used: whether the pointer moved before a click, whether typed text came with key presses, whether the browser says automation controls it. Your proxy already serves it. Load it from your root layout, inside <body>:

app/layout.tsx
import Script from "next/script";// Inside <body>, after {children}:<Script src="/_ethogram/check.js" strategy="afterInteractive" />

The code above answers two paths of its own: /_ethogram/check.js, the script, fetched from https://ethogram.si/ethogram.js and cached for an hour, and /_ethogram/report, where the script sends its counts once when the page loads and again when it's hidden or closed. Each report is forwarded to Ethogram with the request's IP and headers, so it joins the visit it belongs to. Neither path is reported as a page.

It sets no cookie, stores nothing in the browser, and sends only counts and yes-or-no answers: never where the pointer was, what was typed or anything that identifies the person. See Data we keep. Without it, everything else works the same, and most of those agents count as people.

Check it

Deploy, then visit your site the way an AI agent would, with your own domain in place of example.com:

Terminal
curl -A "GPTBot/1.2" https://example.com/

The setup page turns live when the first event arrives. Your test shows up as a spoofed GPTBot, since it doesn't come from OpenAI. That's detection working. Nothing showing up? See the FAQ.