Visitant on Next.js
One <Script> in the root layout counts your visitors, including client-side route changes. One small middleware counts the AI crawlers that never run JavaScript. Both are copy-paste; no SDK, no npm package.
What you need
- A Next.js project (App Router or Pages Router) you can deploy. The examples use TypeScript; the JavaScript version is the same without the types.
- A free Visitant account with your domain added, for example
yoursite.com. - For the AI crawler part: the site's Server key from Site settings - AI crawler tracking (server) in the dashboard, and hosting that runs middleware (a Node.js server, Docker, Vercel or another adapter - not a static
output: 'export'build).
Setup in 5 steps
-
01 Add the Script to the root layout
Open
app/layout.tsx, importScriptfromnext/scriptand render it inside<body>of the root layout, so it is present on every page. Replaceyoursite.comwith your domain.strategy="afterInteractive"is the default: the script loads early, after hydration starts, which is exactly what the Next.js docs recommend for analytics.// app/layout.tsx import Script from 'next/script' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> {children} <Script src="https://annuora.com/v.js" data-domain="yoursite.com" strategy="afterInteractive" /> </body> </html> ) }Pages Router? Put the same
<Script>inpages/_app.tsxnext to<Component {...pageProps} />. Next.js loads it once for every route. Extra attributes such asdata-domainare forwarded to the final<script>element. -
02 Route changes are tracked on their own
Nothing to add here, but worth knowing what happens. Next.js navigates with
history.pushState. The Visitant tracker wrapspushStateonce when it loads, and listens forpopstate(Back and Forward). After every navigation it compares the new URL with the last one it sent and, if different, records a pageview and closes the previous one with its engaged time and scroll depth. Sorouter.push,<Link>and the Back button all count, and a re-render that keeps the URL does not count twice.Hash-only changes (
#section) are ignored unless you adddata-hashto the Script. Do not callvisitant("pageview")yourself in auseEffect- that would double count. -
03 Deploy and open the site
Deploy, open the production domain and click through a few pages. The tracker ignores
localhoston purpose, sonpm run devshows nothing in the dashboard; adddata-localto the Script if you want to test locally. -
04 Count AI crawlers with a middleware
Crawlers such as GPTBot or ClaudeBot never execute the Script, so the client side cannot see them. Create
middleware.tsin the project root (next toappor insidesrc) with the code below and replaceYOUR-KEYwith your Server key. It checks the user agent against the crawler list, and only for a match sends one small request throughevent.waitUntil, so human requests cost nothing. This is the same snippet the dashboard shows with your key filled in.import { NextResponse } from 'next/server'; const AI_UA = /bot|crawl|spider|GPTBot|ChatGPT-User|OAI-SearchBot|Claude|anthropic|Perplexity|Google-|GoogleOther|Applebot|meta-external|Meta-WebIndexer|Amazonbot|Amzn-|Bytespider|CCBot|MistralAI|DuckAssist|YouBot|xAI|Grok|cohere|Diffbot|Kimi|Qwen|Manus|Firecrawl/i; export function middleware(req, ev) { const ua = req.headers.get('user-agent') || ''; if (AI_UA.test(ua)) ev.waitUntil(fetch('https://annuora.com/api/bot', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Visitant-Key': 'YOUR-KEY' }, body: JSON.stringify({ ua, path: req.nextUrl.pathname, ip: req.headers.get('x-forwarded-for') || '' }) }).catch(() => {})); return NextResponse.next(); }Next.js 16 or newer: the file convention was renamed - call the file
proxy.tsand the exported functionproxy(same body, sameevent.waitUntil).middleware.tsstill works but is deprecated;npx @next/codemod@canary middleware-to-proxy .renames it for you. Add amatcherexcluding_next/staticand_next/imageif you prefer to keep the middleware off asset requests. -
05 Confirm the crawler channel
Deploy again. In the dashboard open Site settings - AI crawler tracking (server): the box says Waiting for the first crawler hit until a crawler comes by, then Working: N crawler hits recorded. To test right away, request any page with a crawler user agent:
curl -A "GPTBot/1.4" https://yoursite.com/. Crawler hits never count toward your plan's event limit.Static export (
output: 'export') has no middleware, so this step is impossible there. On Vercel or Netlify without middleware, or behind a CDN that serves cached pages, use the Cloudflare Workers snippet from the dashboard instead - it sees every request, cached or not.
Is it working? Check in 60 seconds
Do these on the deployed site, with the Visitant dashboard open in a second tab.
- The current visitors counter at the top of the dashboard shows 1 while your production site is open.
- Click two internal
<Link>s without reloading: both paths appear in the Pages table, one pageview each. Press Back: the first path gets a second pageview. - In developer tools, Network, filter
api/e: one request with status 202 per navigation, none on re-renders that keep the URL. - Run
curl -A "GPTBot/1.4" https://yoursite.com/; within a minute AI crawlers reading your site on the dashboard lists GPTBot with 1 hit. - Open the site from a ChatGPT answer link (or add
?utm_source=chatgpt.comto a URL): the Sources table shows ChatGPT in the AI channel.
Common problems
| Nothing shows up locally | By design: the tracker does not run on localhost, 127.0.0.1 or file: pages. Add data-local to the Script to test in development, and remove it before deploying if you do not want dev visits counted. |
| Every route change counts twice | Something else also reports pageviews - a manual visitant("pageview") in a useEffect, or the Script rendered twice (root layout and a nested layout, or both _app and _document). Keep exactly one. |
| The Script works but crawler hits stay at zero | Check the Server key in the middleware, make sure the middleware file sits in the project root (or src) at the same level as app, and check your build is not a static export. Also check the matcher does not exclude your pages. |
| TypeScript complains about the middleware signature | Import the types: import type { NextRequest, NextFetchEvent } from 'next/server' and annotate (req: NextRequest, ev: NextFetchEvent). The snippet is plain JavaScript so it works in both .js and .ts files. |
Questions
Should I use beforeInteractive instead?
No. beforeInteractive is for scripts the page cannot work without, and it must sit in the root layout. Analytics is exactly what the Next.js docs list under afterInteractive; the tracker sends the pageview as soon as it runs either way.
Does Visitant work with the React Server Components and streaming?
Yes. The Script is rendered by the root layout, which is a Server Component, and next/script handles the client side. No 'use client' needed because we use no onLoad handler.
Do I need a cookie banner?
Not for Visitant. No cookies, no local storage identifiers, no IP addresses stored, EU hosting. If you already have a banner for other tools, do not gate the Script behind it.
What about the Free plan limits?
5,000 events a month with 2 sites. Pageviews and custom events count; AI crawler hits from the middleware never count. A 14-day Business trial needs no card.
Create a free account - the dashboard shows these steps with your own domain and key filled in.
Start free →