File DA-008 View as Markdown

Next.js

This page puts Double Agent in your Next.js layout, and optionally reports crawlers from your proxy so they show in the portal too. It works on Vercel and anywhere else Next.js runs.

Add the script

Open app/layout.tsx (or src/app/layout.tsx) and add the two Script tags inside <head>. Keep the stub first. Here is a whole root layout with them in place:

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Script id="doubleagent-stub" strategy="beforeInteractive">
          {`window.doubleagent=window.doubleagent||{q:[],push(){this.q.push(arguments)}};`}
        </Script>
        <Script src="https://cdn.doubleagent.so/v1/doubleagent.js" strategy="beforeInteractive" data-profile="auto" />
      </head>
      <body>{children}</body>
    </html>
  );
}

Next.js moves beforeInteractive scripts into the page head wherever they sit in the root layout, so a layout without its own <head> can put them first inside <body> instead.

On the Pages Router, beforeInteractive scripts belong in pages/_document.tsx. Next.js's own example puts them in <body>, and they still end up in the head:

// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';
import Script from 'next/script';

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <Main />
        <NextScript />
        <Script id="doubleagent-stub" strategy="beforeInteractive">
          {`window.doubleagent=window.doubleagent||{q:[],push(){this.q.push(arguments)}};`}
        </Script>
        <Script src="https://cdn.doubleagent.so/v1/doubleagent.js" strategy="beforeInteractive" data-profile="auto" />
      </body>
    </Html>
  );
}

Have a public key? Add data-key="pk_live_…" to the second tag. npx @doubleagent-so/cli init makes either edit for you.

Check it worked

Deploy, then run:

npx @doubleagent-so/cli verify https://your-site.example

It should end with Installed correctly. Then open the site, move to another page with a client-side link, and run await doubleagent.ready() in the console. You should get a verdict with class: 'human'.

To guard an API route or a server action so only people get through, verify the token on the server with requireHuman. That needs a public key.

Crawlers

Crawlers, scrapers and AI fetchers never run the script, so the browser SDK can't see them. To see them in the portal under Crawlers, your proxy reports each request to POST /v1/hits. The report runs after the response, inside event.waitUntil, so pages are never slowed down.

Before you start: you need a verified domain and a live secret key (sk_live_…, from API keys).

  1. Add the secret key as a server-only environment variable named DA_SECRET_KEY. Never prefix it with NEXT_PUBLIC_.
  2. Save this as proxy.ts in your project root (or src/). On Next.js 15 and earlier, name it middleware.ts. If you already have one, call reportHit(req, event) from yours.
// proxy.ts (middleware.ts before Next.js 16)
import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server';

const ENDPOINT = 'https://api.doubleagent.so/v1/hits';
// Headers that help tell a crawler from a browser. Never cookies or credentials.
const HEADERS = ['accept', 'accept-language', 'referer', 'from', 'via', 'sec-fetch-site', 'sec-fetch-mode',
  'sec-fetch-dest', 'sec-ch-ua', 'sec-ch-ua-platform', 'signature-agent', 'signature-input'];
// Vercel sets these headers itself. Anywhere else a client can fake them, so they are left out.
const ON_VERCEL = process.env.VERCEL === '1';

const buffer: Record<string, unknown>[] = [];
let sending: Promise<void> | null = null;

/** One request as a hit: method, host, path and status. Never the query string. */
export function hitOf(req: NextRequest, status: number): Record<string, unknown> {
  const hd: Record<string, string> = {};
  for (const name of HEADERS) {
    const value = req.headers.get(name);
    if (value) hd[name] = (name === 'referer' ? value.split('?')[0] : value).slice(0, 512);
  }
  const ua = req.headers.get('user-agent');
  const ip = ON_VERCEL ? req.headers.get('x-real-ip') : null;
  const country = ON_VERCEL ? req.headers.get('x-vercel-ip-country') : null;
  return {
    id: crypto.randomUUID(), ts: Date.now(), m: req.method, h: req.nextUrl.host, p: req.nextUrl.pathname, s: status,
    hd, ...(ua ? { ua: ua.slice(0, 1024) } : {}), ...(ip ? { ip } : {}), ...(country ? { cf: { country } } : {}),
  };
}

/** Sends what has piled up after 5 seconds, at most 100 hits per request. A failed send is dropped. */
async function sendSoon(): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  sending = null;
  const hits = buffer.splice(0, 100);
  try {
    await fetch(ENDPOINT, {
      method: 'POST',
      headers: { authorization: `Bearer ${process.env.DA_SECRET_KEY}`, 'content-type': 'application/json' },
      body: JSON.stringify({ v: 1, hits }),
      signal: AbortSignal.timeout(5000),
    });
  } catch {
    // Reporting must never break the site.
  }
}

export function reportHit(req: NextRequest, event: NextFetchEvent, status = 200): void {
  if (!process.env.DA_SECRET_KEY) return;
  // Skip the app's own background fetches (client-side navigation); keep pages and anything without the header.
  const dest = req.headers.get('sec-fetch-dest');
  if (dest && dest !== 'document' && dest !== 'iframe') return;
  buffer.push(hitOf(req, status));
  sending ??= sendSoon();
  event.waitUntil(sending);
}

export default function proxy(req: NextRequest, event: NextFetchEvent): NextResponse {
  reportHit(req, event);
  return NextResponse.next();
}

export const config = {
  // Pages and crawler files (robots.txt, sitemaps, llms.txt). Skips static assets, images and API routes.
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|svg|webp|ico|css|js|map)$).*)'],
};
  1. Deploy. Within a few minutes, fetch a page with curl https://your-site.example/ and look under Crawlers in the portal. curl shows up as a script.

What this can and can't see: the proxy runs before the page, so it reports status 200 for every request it passes on. A sweep of missing pages (many 404s from one client) therefore doesn't show from Next.js. Requests for probe paths like /.env or /wp-login.php do show, under Threats. Query strings are never sent, so injection attempts in the query need the full edge reporter; see Intent.

Next: claim your domain if you haven't, then read the Verdict.