Guide
Add analytics to a Next.js App Router app
Install lytics once in your root layout. Count pageviews as visitors move between routes without full page loads, then send a named event from a client-side button.
next/link navigation. Other Next.js versions, Pages Router, multiple root layouts and custom routers have not been verified.1. Create an account and an app
- Create an account or sign in, then open Your apps.
- Under Add an app, enter a name and comma-separated production hostnames, for example
example.com, www.example.com, localhost. Use hostnames without protocols, ports or paths. Add preview hosts separately if you want to test those.localhostallows local development; remove it in Settings when you no longer need it. - Open the app and copy the snippet from its setup or Install section. It contains the queue initializer and your property’s script URL. That generated snippet is the source of truth.
Hosts must match the allowlist; www.example.com and example.com are different. An empty list accepts all hosts, so explicitly configure it before publishing.
2. Install once in the root layout
In an existing TypeScript App Router app, adapt the root layout below, retaining your fonts, styles and metadata. If you use a src/ directory, put every example under src/app/ instead of app/. Use the initializer and external src from your generated snippet:
// app/layout.tsx (or src/app/layout.tsx)
import Script from 'next/script'
import type { ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Script id="lytics-queue" strategy="beforeInteractive">
{`window.lytics=window.lytics||function(){(lytics.q=lytics.q||[]).push(arguments)};`}
</Script>
{children}
<Script
id="lytics-tracker"
src="https://lytics.lp-data.cloud/api/t/lyt_REPLACE_WITH_YOUR_SITE_KEY.js"
strategy="afterInteractive"
/>
</body>
</html>
)
}lyt_REPLACE_WITH_YOUR_SITE_KEY with your app’s key, or copy the entire script URL from Install. This placeholder cannot collect analytics.The root layout remains a Server Component: it renders the script declarations, but never calls browser globals on the server. next/script runs them in the browser. The inline script needs a stableid. Its beforeInteractive strategy establishes the queue before hydrated buttons can call it. The external afterInteractive script drains that queue, replaces the function, then sends the initial pageview. Calls made while the download is pending are queued.
The external script executes as a real script element, so the tracker reads document.currentScript.srcand sends to /api/collect on that URL’s origin. Its configured endpoint is only a fallback. Keep the tracker external: do not paste its downloaded contents into an inline Script or fetch and evaluate it. Do not use the worker strategy. This integration replaces the generated snippet’s defer loading with Next.js script scheduling.
Next.js keeps the root layout and loads this script once across its routes. The tracker wrapshistory.pushState and replaceState and listens for popstate. Do not add a usePathname effect or call lytics('page') on every render or route change: that would double count. See the Next.js script guidefor layout scoping and loading strategies.
3. Add two routes and one client-side event
These files form a minimal example with the layout above. The pages are Server Components. Only the button needs 'use client', because its click handler uses window. The type declaration describes the queue and loaded API; it does not initialize either one.
// app/TrackButton.tsx
'use client'
// Also describes the queue function before the tracker finishes loading.
declare global {
interface Window {
lytics: (command: string, ...args: unknown[]) => void
}
}
export default function TrackButton() {
return (
<button type="button" onClick={() => {
window.lytics('event', 'demo_button_clicked', { source: 'nextjs_guide' })
}}>
Send demo event
</button>
)
}// app/page.tsx
import Link from 'next/link'
import TrackButton from './TrackButton'
export default function Home() {
return (
<main>
<h1>Home</h1>
<Link href="/about">About</Link>
<TrackButton />
</main>
)
}// app/about/page.tsx
import Link from 'next/link'
import TrackButton from '../TrackButton'
export default function About() {
return (
<main>
<h1>About</h1>
<Link href="/">Home</Link>
<TrackButton />
</main>
)
}Each click sends demo_button_clicked with a fixed source label. Keep event names and properties free of personal data: no emails, form values or user-entered text. You do not need identity integration to count pageviews or events. If you later need signed-in users, use an opaque internal id and follow the optionalidentify, login and logout reference.
For dashboard-managed click and form events instead, follow the declarative events guide. Choose one handler or tag per intended event.
4. Confirm your first pageview and navigation
- Run
npm run devwithlocalhostallowed, or deploy to an allowed production host. - Keep the lytics app dashboard open. In another ordinary browser tab, open your Next.js home page. Setup confirms its first pageview; Live shows a
pageviewfor/. Reload the dashboard to see today’s totals (UTC). - Open your browser’s Network panel. The property’s
/api/t/…jsshould return 200; the initial event goes to the lytics origin’s/api/collect. A 204 alone does not prove it was stored. - Click About: expect one pageview for
/about. Use browser Back then Forward: expect one pageview for each destination, without another tracker installation. - Click Send demo event once: expect exactly one
demo_button_clickedevent. Navigate Home and click again to check it still sends once. - Stop the dev server, run
npm run buildthennpm run start, and repeat in a fresh tab. This checks the production bundle as well as development.
A full reload sends a new pageview. A transition to a different pathname sends one pageview; changing only the query string or fragment sends none. The tracker compares pathnames, so refreshes of route data at the same path do not count as new pages. The tested case covers Link navigation and browser back/forward in a shared root layout; verify redirects, rewrites and any custom navigation in your own app. Navigation before the tracker finishes loading cannot be reconstructed: the initial pageview is for the path current when it executes.
What the numbers mean, and what is stored
The tracker sets no cookies and writes no identifiers to localStorage or sessionStorage. Optional identity is held in page memory until a full reload (or logout). The browser can cache the tracker script for five minutes. Page URLs are sent to the collector; it retains the pathname, discarding query strings and fragments.
Visitors are counted per UTC day using a hash of the day, property, IP address and user agent. Daily salts are deleted after the following day. Across a date range, Visitors sums daily counts, rather than deduplicating people across days. A changed IP can count someone again; people sharing an IP and browser can count together. These are counting and storage details, not a compliance determination.How lytics counts visitors without cookies explains the hash and its trade-offs step by step.
See the installation reference for visits-only mode andthe docs for metric definitions and retention.
Troubleshooting
| Symptom | What to check |
|---|---|
| Script returns 404 | Replace the placeholder with the src from this app’s Install section. A deleted app or a wrong key also returns “unknown site key”. |
| Script never loads | Check Network and the console for blockers and CSP errors. Allow the lytics origin in script-src and connect-src, and authorize the inline queue with your CSP nonce or hash. Do not disable CSP to install analytics. |
| 204 responses but no dashboard pageview | 204 does not confirm acceptance: the collector returns it even for dropped events. Check the app’s key and allowed domains, including localhost, www and preview hosts. Bots and headless browser traffic are dropped; confirm with an ordinary browser. |
| Two events per click or navigation | Keep this installation only in the root layout. Remove old snippets, manual page calls, route effects and duplicate click tags. A full reload after cleanup removes old listeners. Do not combine this button handler with a dashboard tag for the same click. |
| Development numbers look surprising | A full reload, including one triggered by Fast Refresh, starts a new tracker instance and sends a pageview. Ordinary React renders and Strict Mode renders do not install this script again. Compare a clean browser load with next build && next start before diagnosing duplicates. |