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).
- Add the secret key as a server-only environment variable named
DA_SECRET_KEY. Never prefix it withNEXT_PUBLIC_. - Save this as
proxy.tsin your project root (orsrc/). On Next.js 15 and earlier, name itmiddleware.ts. If you already have one, callreportHit(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)$).*)'],
};
- Deploy. Within a few minutes, fetch a page with
curl https://your-site.example/and look under Crawlers in the portal.curlshows 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.