Add analytics to Next.js
A cookieless tracker for the App Router and the Pages Router, with no route-change effect to write.
App Router
Import next/script in your root layout at app/layout.tsx:
// 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://www.staats.ai/s.js"
data-site="YOUR_SITE_CODE"
strategy="afterInteractive"
/>
</body>
</html>
)
}
afterInteractive is the default, so you can leave the prop off entirely. It is
spelled out above only to be explicit about the intent: load early, but after Next.js has done its
own work. beforeInteractive is the wrong tool here. It is reserved for scripts the page
cannot function without, such as consent managers and bot detection, and it has to sit in a root
layout. A tracker does not need to run before your application does.
Pages Router
Put the plain tag in pages/_document.tsx so it renders on every page:
// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head>
<script defer data-site="YOUR_SITE_CODE" src="https://www.staats.ai/s.js" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
Use _document.tsx, not _app.tsx. A tag rendered from
_app is re-evaluated on client navigation, which is how duplicate pageviews start.
You do not need a usePathname effect
Most Next.js analytics guides tell you to add a usePathname or
useRouter effect that fires a pageview whenever the route changes. Skip it. The tracker
already hooks pushState, replaceState and popstate, which is
what the Next.js router uses under the hood for client-side navigation.
Adding the effect on top does not improve coverage. It double-counts every soft navigation, and because the duplicate is a real event from a real visitor, nothing in your numbers looks obviously wrong until you compare pageviews against visitors and find the ratio has quietly doubled.
Tracking a click
Add data-track to any clickable element. It works in server components, because
there is no client-side handler involved:
<button data-track="signup_click">Start free</button>
Inside a client component, or for anything that is not a click, call window.track
from your own handler:
'use client'
export function CheckoutButton() {
return <button onClick={() => window.track('checkout_started')}>Buy</button>
}
No cookie banner
The script is around 1.5KB, sets no cookies, and writes nothing to the visitor's device. Visitors are counted with a pseudonymous hash that rotates daily, and country is derived from the browser timezone rather than an IP lookup. Nothing requires consent, which means no banner and no layout shift on first paint.
Local development
Leave the snippet in while you work. localhost is never recorded, so
next dev stays out of your production numbers with no environment check and no
conditional rendering in your layout.
One thing to check: if app/not-found.tsx inherits the root layout, every mistyped URL
and crawler probe is recorded as a pageview for a page that does not exist. Those paths then sit in
your stats looking real. Give the 404 a layout without the snippet.
Reading the data
Staats has no dashboard. You connect it to your coding agent once, and then you ask. Because the connection is a Model Context Protocol server, the agent can pull traffic, referrers, and funnels into the conversation you are already having about the code.
> did the new pricing page help?
The agent also stores what the site is for and what each event means, so a question three weeks later does not start from nothing. See MCP Server and Agent Workflows.
Get a site code
Create a free account, add your site, and paste the snippet above. The free plan covers side projects, and there is no card required.