Site Tracking
Site Tracking for Next.js
Send AI crawler visits and AI referrals from a Next.js site to Ranqo with one middleware file. Works on Next.js 15 and 16.
Ranqo's Site Tracking sees every request your server handles, including the AI crawlers that never run JavaScript and so never reach GA4. On Next.js it is one middleware file.
Copy your site key
Open Traffic in the dashboard and choose Set up Site Tracking. Select Generate site key, then copy the key. Keys start with
rk_live_.Add the key to your environment
.envRANQO_SITE_KEY=rk_live_YOUR_SITE_KEYSet it in your hosting provider's environment variables too, for every environment you want tracked.
Add the middleware
Create
middleware.tsat the root of your project, next toapp/orpages/. On Next.js 16, name the fileproxy.tsand the functionproxy.middleware.ts// middleware.ts (Next.js 15) — or proxy.ts (Next.js 16, rename function to `proxy`) import { NextRequest, NextResponse } from 'next/server'; // OPTIONAL: skip self-tracking on subdomains you don't want counted as // "marketing site visitors" (e.g. your authenticated dashboard on // app.yoursite.com). Most teams can leave this empty. const SKIP_HOSTS = new Set<string>([ // 'app.yoursite.com', // 'admin.yoursite.com', ]); // The header your CDN writes with the visitor's country: 'cf-ipcountry' // behind Cloudflare, 'cloudfront-viewer-country' behind CloudFront. On Vercel // this is ignored: Vercel writes x-vercel-ip-country itself. Left empty, no // country is sent. Name only a header your CDN sets on every request: a visitor // can send any header themselves. const COUNTRY_HEADER = ''; // How many proxies in front of your server add to X-Forwarded-For (nginx, a // load balancer, a CDN). The visitor's address is read that many entries from // the right of the header, the part your own proxies wrote; a visitor can put // any address to the left of it. On Vercel this is ignored: Vercel writes the // header itself. At 0 no address is sent, because middleware cannot see the // connection and `next start` passes on whatever X-Forwarded-For a visitor sends. const TRUSTED_PROXY_COUNT = 0; export function middleware(request: NextRequest) { // Only track GET. POST/PUT/DELETE/HEAD/OPTIONS never represent a page view. if (request.method !== 'GET') return NextResponse.next(); // CRITICAL — skip Next.js prefetch / RSC requests. Without these checks, // every <Link> hover fires a fake page view; traffic inflates 5-10x. const secFetchDest = request.headers.get('sec-fetch-dest'); if (secFetchDest && secFetchDest !== 'document') return NextResponse.next(); if (request.headers.get('next-router-prefetch')) return NextResponse.next(); if (request.headers.get('next-router-segment-prefetch')) return NextResponse.next(); if (request.headers.get('rsc') === '1') return NextResponse.next(); const secPurpose = request.headers.get('sec-purpose'); if (secPurpose && secPurpose.startsWith('prefetch')) return NextResponse.next(); if (request.headers.get('purpose') === 'prefetch') return NextResponse.next(); const { pathname } = request.nextUrl; if (pathname.startsWith('/api/')) return NextResponse.next(); if (SKIP_HOSTS.has(request.headers.get('host') || '')) return NextResponse.next(); const params = new URLSearchParams({ url: request.url, userAgent: request.headers.get('user-agent') || '', ref: request.headers.get('referer') || '', ip: visitorIp(request), country: visitorCountry(request), websiteKey: process.env.RANQO_SITE_KEY!, }); fetch(`https://app.ranqo.ai/api/v1/intake/pageview?${params}`).catch(() => {}); return NextResponse.next(); } function visitorIp(request: NextRequest): string { const proxies = process.env.VERCEL ? 1 : TRUSTED_PROXY_COUNT; if (proxies < 1) return ''; const hops = (request.headers.get('x-forwarded-for') || '').split(',').map((h) => h.trim()).filter(Boolean); return hops[Math.max(hops.length - proxies, 0)] || ''; } function visitorCountry(request: NextRequest): string { const header = process.env.VERCEL ? 'x-vercel-ip-country' : COUNTRY_HEADER; return (header && request.headers.get(header)) || ''; } // AI-discovery files (robots.txt, llms.txt, sitemap.xml, .well-known/*) are // the highest-signal hits — allowlist them above the static-asset skip rule. export const config = { matcher: [ '/robots.txt', '/llms.txt', '/llms-full.txt', '/sitemap.xml', '/sitemap-:path*', '/ai.txt', '/.well-known/:path*', '/((?!api|_next/static|_next/image|favicon.ico|manifest.webmanifest|.*\\.(?:png|jpg|jpeg|svg|gif|webp|ico|css|js|mjs|map|woff2?|ttf|otf|eot|txt|xml|json)$).*)', ], };Hosting somewhere other than Vercel? Set
TRUSTED_PROXY_COUNTat the top of the file first, as The visitor's address explains, or the middleware sends no visitor address.Deploy and verify
Deploy, then run the check in Verify your install. The install page in the dashboard also watches for your first real visit and confirms it when it arrives.
What the middleware does
- Never slows your pages. The call to Ranqo is fired without waiting for an answer, so it adds no latency.
- Counts page views only. It skips
POSTrequests, API routes, and the prefetch and RSC requests Next.js makes when a visitor hovers a link. Without those checks, a site's traffic reads five to ten times higher than it is. - Keeps the files AI crawlers read first.
robots.txt,llms.txt,sitemap.xmland/.well-known/*are the highest-signal requests an AI crawler makes, so the matcher lets them through ahead of the static-file rule. - Skips images, fonts, scripts and styles, which are noise.
- Sends an address the visitor cannot choose. On Vercel it reads the
X-Forwarded-Forentry Vercel writes; elsewhere, the entry your own proxies added, counted from the right. See The visitor's address. - Sends the visitor's country on Vercel, from Vercel's
x-vercel-ip-countryheader. Hosted elsewhere, setCOUNTRY_HEADERto your CDN's country header, such ascf-ipcountrybehind Cloudflare; until then, visits are recorded without a country.
The visitor's address
Ranqo checks each crawler visit against the IP ranges the crawler's operator publishes, so the report has to carry an address the visitor cannot choose. The first entry of X-Forwarded-For is not one: anyone can send the header with Googlebot's address in it, and a proxy adds its own entry to the right of whatever arrived.
- On Vercel, nothing needs setting. Vercel replaces any
X-Forwarded-Fora visitor sends with the address it saw, and the middleware reads that. - Anywhere else, set
TRUSTED_PROXY_COUNTto the number of proxies in front ofnext startthat add toX-Forwarded-For:1behind nginx, Caddy or one load balancer,2behind a CDN in front of your own load balancer. The middleware reads the header that many entries from the right, the part your proxies wrote. Count only proxies every request passes through, and keepnext startreachable only through them: a visitor who reaches it directly can write as many entries as you count. - Left at
0, the default off Vercel, no address is sent. Middleware cannot see the connection, andnext startpasses on whateverX-Forwarded-Fora visitor sends, so nothing in the request can be trusted. Visits are still recorded, but crawler visits come out Not checkable and every visit counts as the same visitor.
To find the count, log the header for one deploy (console.log(request.headers.get("x-forwarded-for")) in the middleware) and send your site a request with a made-up entry, such as curl -H "X-Forwarded-For: 192.0.2.1" https://www.example.com/. Find your own public address in the logged header: its position counted from the right is the number to use. If only 192.0.2.1 arrives, nothing in front of the server adds to the header, so leave the count at 0.