Staats

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.