Next.js analytics in one prompt

undefined

Updated

Paste the prompt below into your coding agent (Claude Code, Cursor, Codex or any agent that can make HTTP requests) inside your Next.js project. It gets a key with one request, creates the site, serves the tracker from your own domain through proxy.ts, puts it in the root layout, deploys, and checks the live site until the setup check passes. The steps after the prompt are what it does, so you can check its work or do it by hand. For other tools and platforms, start at add analytics with an AI agent.

The prompt

Paste this into your coding agent, in the Next.js project

Add analytics to this Next.js app with AgentFirstStack. It's free, open-source analytics built for agents, and you can do all of it yourself: there is no signup form, and no email or verification is needed. 1. Read https://agentfirststack.com/llms.txt and https://analytics.agentfirststack.com/llms.txt. 2. If .env.local already has AGENTFIRSTSTACK_API_KEY, use it and read AGENTFIRSTSTACK.md. Otherwise get a key with one request: curl -X POST https://agentfirststack.com/v1/signup Save api_key in .env.local as AGENTFIRSTSTACK_API_KEY (never with a NEXT_PUBLIC_ prefix) and in the host's production environment variables. Name the project after the domain: PATCH https://agentfirststack.com/v1/me. 3. Create a site for the production domain and read GET /v1/sites/:id/install?framework=nextjs. Serve the tracker from this app's own domain: add the first-party proxy from first_party_proxy.nextjs as proxy.ts (middleware.ts before Next.js 16), reading the proxy key from the server-only env var AFS_PROXY_KEY (set it in .env.local and in production). Then add <script async src="/_a/s.js" data-site="..."> inside <head> in app/layout.tsx, so it's in the server-rendered HTML (Pages Router: with defer in pages/_document.tsx). 4. Create goals for sign-ups, purchases and the main buttons. Send conversions only the server knows about (a paid order, a verified account) as server events from a Route Handler or Server Action, with afsa.visitorId() passed from the page. 5. Add an email destination, a weekly report, and alerts for a traffic drop and for no data. 6. Deploy (or tell me to), open the production site once, then run verify and fix what it reports. 7. Write the site id and the env var names (never the values) in AGENTFIRSTSTACK.md, and create a claim link for me with POST https://agentfirststack.com/v1/claim-link. 8. Run GET https://agentfirststack.com/v1/setup?services=analytics and work through its next_steps until it says "ready": true. When you're done, tell me what you set up, how you verified it, and give me the claim link.

First, a proxy on your own domain

Ad blockers block requests to analytics.agentfirststack.com. So the tracker is served from your own domain: proxy.ts forwards /_a/* to AgentFirstStack, and the page loads /_a/s.js. The tracker posts to the path it was loaded from (/_a/e), so the script and its events are both first-party, and visitors with ad blockers are counted too.

The proxy has to send one header: x-afs-proxy-key, the site's own secret (first_party_proxy.proxy_key in the install answer). The collector only trusts the visitor IP in X-Forwarded-For and the geo headers from a request that carries it, because anyone can send those headers. Without it, every request comes from your server's address: all visitors merge into a few, the country is your server's, and attack protection can set real visitors aside as one network over its daily share. Verify reports it as proxy_key, and the setup check shows analytics.proxy_key failing.

On Next.js 16 the file is proxy.ts; on 13 to 15 it's middleware.ts and the function is named middleware (Next.js proxy docs). first_party_proxy.nextjs in the install answer has the proxy part, with the key written in; this version reads the key from an environment variable instead and also reports AI crawlers, since a project gets one proxy file:

// proxy.ts (Next.js 16+; on 13-15: middleware.ts with "export function middleware")
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";

const A = "https://analytics.agentfirststack.com";
// Vercel's geo headers, mapped to the collector's. On other hosts, map yours or leave this out.
const GEO: Record<string, string> = { country: "x-vercel-ip-country", "region-code": "x-vercel-ip-country-region",
  city: "x-vercel-ip-city", timezone: "x-vercel-ip-timezone", lat: "x-vercel-ip-latitude", lon: "x-vercel-ip-longitude" };
// Or fetch the current list: GET https://analytics.agentfirststack.com/v1/crawlers (prefilter)
const BOTS = /gptbot|oai-searchbot|chatgpt-user|claudebot|claude-user|claude-searchbot|perplexity|googlebot|bingbot|applebot|ccbot|bytespider|amazonbot|meta-external|bot\b|bot\/|crawl|spider/i;

export function proxy(request: NextRequest, event: NextFetchEvent) {
  const { pathname, search } = request.nextUrl;

  // 1. First-party analytics: /_a/s.js and /_a/e
  if (pathname.startsWith("/_a/")) {
    const headers = new Headers(request.headers);
    for (const k of [...headers.keys()]) if (k.startsWith("x-afs-")) headers.delete(k); // never pass on visitor-sent values
    headers.set("x-afs-proxy-key", process.env.AFS_PROXY_KEY!);
    headers.set("x-forwarded-for", (request.headers.get("x-forwarded-for") || "").split(",")[0].trim());
    for (const [k, h] of Object.entries(GEO)) {
      const v = request.headers.get(h);
      if (v) headers.set("x-afs-geo-" + k, v);
    }
    headers.delete("cookie");
    return NextResponse.rewrite(new URL(pathname + search, A), { request: { headers } });
  }

  // 2. Bots don't run the tracker: report them, without waiting for the answer
  const ua = request.headers.get("user-agent") || "";
  if (BOTS.test(ua))
    event.waitUntil(
      fetch(`${A}/v1/sites/${process.env.AFS_SITE}/crawls`, {
        method: "POST",
        headers: { authorization: `Bearer ${process.env.AGENTFIRSTSTACK_API_KEY}`, "content-type": "application/json" },
        body: JSON.stringify({ user_agent: ua, path: pathname }),
      }).catch(() => {}),
    );
  return NextResponse.next();
}

export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };

Proxy doesn't run in a static export (output: "export"); the static exports page lists it as unsupported, along with rewrites. There, proxy at the host. GET /v1/sites/:id/install returns configs filled in with the proxy key for a Cloudflare Worker route, a vercel.json route with a request-header transform, netlify.toml and nginx; on Netlify, the Edge Function in the Astro guide also passes Netlify's location data. The Cloudflare Worker sends the most exact location (country, region and city from Cloudflare's edge), so prefer it when your domain is on Cloudflare.

Location on other hosts. On Vercel, the proxy maps Vercel's x-vercel-ip-* headers. On a host without location headers, the collector only sees your server, so country and city show your server's location; visitor counts stay right. If you add the tag manager too, proxy it the same way under /_t/* with the configs and its own key from GET /v1/containers/:id/install?proxy=1.

Don't try a Route Handler at app/_a/...: Next.js treats folders starting with an underscore as private and doesn't route them.

Then, the tag in the root layout

App Router. The root layout, app/layout.tsx, wraps every page, so one tag there covers the whole app:

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <script async src="/_a/s.js" data-site="site_..." />
      </head>
      <body>{children}</body>
    </html>
  );
}

Why a plain <script async> and not next/script? GET /v1/sites/:id/install?framework=nextjs returns a <Script strategy="afterInteractive"> for the direct snippet, and that tracks fine. But the Next.js docs say afterInteractive scripts are injected on the client after hydration, and in the App Router the server HTML only carries a preload hint for them. verify reads the server HTML, so it reports snippet_present as failing and passes on events alone. It also can't prove the domain from the homepage, which the setup check wants. React renders <script async src> into the server HTML and moves it into <head>, so verify finds it. Use async rather than defer here: React's docs say defer isn't compatible with streaming server rendering.

If you'd rather keep next/script, prove the domain with a DNS TXT record instead: _afs-verify.example.com with the value afs-analytics-site=site_..., then run verify again.

Pages Router. Put the tag in pages/_document.tsx, which only renders on the server:

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

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <script defer src="/_a/s.js" data-site="site_..." />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

The site id in data-site is public: it only lets a browser send page views for your domain. It doesn't need an environment variable.

Route changes and custom events

The tracker wraps history.pushState and replaceState and listens for popstate, so every <Link> click and router.push is a page view. You don't need a usePathname effect, and adding one would count pages twice.

The tracker keys a page on its path plus query string. If a page keeps UI state in the query (filters, tabs, a search box), each change is a new page view of the same path. Usually that's what you want; if it's noise, drop those page views with data-before-send (in the analytics manual).

Events need no JavaScript for buttons and links:

<button data-afsa-event="signup" data-afsa-prop-plan="pro">Start free</button>

From a client component, call the global the tracker defines:

"use client";
declare global { interface Window { afsa?: (...a: unknown[]) => void } }

export function BuyButton() {
  return <button onClick={() => window.afsa?.("event", "begin_checkout", { plan: "pro" })}>Buy</button>;
}

Keep the key on the server

The browser only ever sees the site id. The API key (AGENTFIRSTSTACK_API_KEY) is for server code: creating goals, verify, server events, crawler reports. Next.js keeps variables without the NEXT_PUBLIC_ prefix out of the browser; its docs say prefixed ones are inlined "into any JavaScript sent to the browser". So never rename it NEXT_PUBLIC_AGENTFIRSTSTACK_API_KEY to make an error go away. If a client component needs something from AgentFirstStack, add a Route Handler that calls the API.

The proxy adds two more, also server-only: AFS_PROXY_KEY (the site's proxy key, a secret) and AFS_SITE (the site id, for crawler reports). On Vercel, add all three under the project's Environment Variables for Production and Preview. A missing AFS_PROXY_KEY doesn't break the page: events still arrive, but without the key, and verify reports proxy_key.

Server events and revenue

The browser can't see a verified account or a paid order. Pass the visitor id from the page with the request that causes it, then send the event from the server:

// in the browser, with the sign-up form
body: JSON.stringify({ email, visitorId: window.afsa?.visitorId?.() })

// app/api/signup/route.ts (or a Server Action)
await fetch(`https://analytics.agentfirststack.com/v1/sites/${process.env.AFS_SITE}/events`, {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.AGENTFIRSTSTACK_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({ events: [{ name: "signup", visitor_id: visitorId, user_id: user.id }] }),
});

With a visitor_id, the event joins that visit, so the sign-up counts for the source and page that brought the visitor. For Stripe, put the same id in the Checkout Session's metadata.afs_visitor_id and connect Stripe once (POST /v1/sites/:id/integrations/stripe/connect with a restricted key); every payment then lands on its source.

Check it on the live site

  1. Open the production URL once in a normal browser. Headless browsers are filtered out unless the visit is marked as test traffic (?afsa_debug=1).
  2. Run verify: POST https://analytics.agentfirststack.com/v1/sites/:id/verify. It fetches the page and checks the snippet, that /_a/s.js really serves the tracker through your proxy, your Content-Security-Policy, whether events arrived and whether they carried the proxy key, and it returns fixes.
  3. Run the setup check: GET https://agentfirststack.com/v1/setup?services=analytics until "ready": true. It covers what verify can't see: goals that have never counted, the owner's email and weekly report, alerts and the claim link.

If you set a Content-Security-Policy, the proxied tracker only needs 'self' in script-src and connect-src. Verify names the directive that blocks it.

Vercel preview deployments run on their own *.vercel.app hosts. A site accepts browser events only from its domain and its subdomains, so preview traffic is dropped as hostname_not_allowed and stays out of your numbers; verify lists the hosts it dropped. If the app has no custom domain yet, create the site for its own hostname (myapp.vercel.app); vercel.app on its own is refused because it's a shared suffix.

FAQ

Do I need a router hook for App Router navigation?

No. The tracker counts every History API navigation, which is what the App Router and Pages Router both use. A usePathname effect that sends page views would count each page twice.

Why does verify say snippet_present failed when events are arriving?

You're probably using next/script with afterInteractive. Next.js adds that script in the browser after hydration, so it isn't in the HTML verify reads. Tracking works either way; switch to a plain <script async> in the root layout, or add the _afs-verify DNS TXT record, so the domain can be verified.

Does it work with output: "export"?

Yes. Proxy, rewrites and Route Handlers that read the request don't run in a static export, so put the first-party proxy in the host instead (a Cloudflare Worker route, a Netlify Edge Function, vercel.json or nginx) and send server events from wherever your backend runs.

Can I skip the proxy and load the script from analytics.agentfirststack.com?

Yes, the direct snippet works anywhere. But ad blockers block that host, so visitors who use one won't be counted. Through /_a/ on your own domain they are.

Does it need a cookie banner?

The tracker sets no cookies and stores nothing in the browser by default: a visitor is a daily-salted hash of the IP address and user agent. Whether you need a banner is a legal question for your site. If your consent manager should decide, add data-require-consent="true" and call afsa('consent', true) after consent.

Can I keep Vercel Web Analytics or GA4 running too?

Yes. They don't interact. Keep the old tool for a week, import its history, and compare the daily visitors with GET /v1/sites/:id/imports/compare before you remove anything.

Next.js and Vercel are trademarks of Vercel, Inc. AgentFirstStack isn't affiliated with Vercel or Meta. Next.js, React and Vercel details come from their docs, checked on 6 November 2026; the next/script behaviour was also read in the Next.js 16.2.6 source.