Privacy-First Analytics for Next.js: A Complete Setup Guide
App Router, Pages Router, Server Components, custom events, CSP. Everything you need, none of what you don't.
Adding analytics to a Next.js app should be one script tag. In practice, you also have to think about App Router vs Pages Router, when to use next/script, how Server Components fire custom events (they can't — directly), and what your CSP allows.
This guide walks through the full setup for Next.js 15, using Datibase as the analytics tool. The patterns apply to any cookie-free script-based analytics — Plausible, Fathom, Umami — with minor URL changes.
Decide where the script goes
You have three places to put an analytics script in a Next.js app, and the right one depends on which router you use and what else the script needs to do.
- App Router → root layout, next/script: Loads once, persists across navigations, gets afterInteractive timing. This is the right answer for 95% of App Router projects.
- Pages Router → _app.tsx, next/script: Same idea, different mount point. Loads once for the whole app and survives client navigations.
- Plain <script> in <head>: Avoid unless you know why. Plain script tags in App Router don't get next/script's deduping, prefetch hints, or strategy controls.
App Router setup
Drop next/script into your root app/layout.tsx. Use strategy="afterInteractive" so it loads after hydration but doesn't block initial paint.
// 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://datibase.dev/api/tracker/<code>"
strategy="afterInteractive"
/>
</body>
</html>
);
}Replace <code> with the tracking code from your dashboard (Dashboard → website → install_tracker). Page navigations are tracked automatically — the loader hooks history.pushState / replaceState so client-side route changes register as new pageviews without extra code.
Pages Router setup
Same script tag, mounted in _app.tsx instead of layout. The router-event listener pattern is unnecessary — the loader handles SPA navigation internally.
// pages/_app.tsx
import Script from "next/script";
import type { AppProps } from "next/app";
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Component {...pageProps} />
<Script
src="https://datibase.dev/api/tracker/<code>"
strategy="afterInteractive"
/>
</>
);
}Custom events from Client Components
Track signups, plan changes, or any user action by calling the global datibase function from a Client Component. The first argument is the literal "event":
// components/signup-button.tsx
"use client";
declare global {
interface Window {
datibase?: (...args: unknown[]) => void;
}
}
export function SignupButton({ plan }: { plan: string }) {
return (
<button
onClick={() => {
window.datibase?.("event", "signup_clicked", { plan });
}}
>
Start {plan}
</button>
);
}A common gotcha worth repeating from our GA migration guide: if you drop the literal "event" argument, the call queues silently and never fires. The handler dispatches on the first arg.
Tracking from Server Components and Server Actions
Server Components run on the server with no window — they cannot fire client-side analytics events directly. Two common patterns to bridge the gap:
Pattern A: fire from a Client Component child
The Server Component renders a small Client Component that fires the event in useEffect. Cleanest for one-shot events tied to rendering a page (e.g., a confirmation page after checkout).
// app/checkout/success/page.tsx — Server Component
import { TrackPurchase } from "./track-purchase";
export default async function SuccessPage() {
// fetch order details on the server
const order = await getOrder();
return (
<>
<h1>Thanks!</h1>
<TrackPurchase plan={order.plan} />
</>
);
}Pattern B: fire from the server via your API
For events that must be tamper-proof (paid signups, refunds), do not trust the client. Have your server post the event to your own API, which then attributes the event to the user via identify. For Datibase users, the Stripe / Polar webhook integration handles this automatically — connect once and revenue events appear with referrer attribution preserved.
Identifying paid users
Once a visitor logs in or completes checkout, identify them with the corresponding Stripe (or Polar) customer ID. This is the single line of code that connects traffic to revenue:
// after login or successful checkout
window.datibase?.identify({ customerId: stripeCustomerId });If you sign in users via better-auth or NextAuth, fire this in a Client Component that reads the session — not in your server-side auth callback. The visitor session lives in the browser; the identification has to happen there to link the two.
Content Security Policy
If you set a CSP via next.config.ts or middleware, allow the analytics origin in two directives:
script-src 'self' https://datibase.dev;
connect-src 'self' https://datibase.dev;The script-src directive permits loading the loader and the inner script.js; connect-src permits the fetch calls to /api/analytics/*. Skipping either causes a silent CSP block — events appear to fire but nothing arrives at the dashboard.
Excluding internal traffic
Don't pollute your funnel with visits from your own dev environments. The cleanest way is to gate the script behind an env var so it only runs in production:
// app/layout.tsx
{process.env.VERCEL_ENV === "production" && (
<Script
src="https://datibase.dev/api/tracker/<code>"
strategy="afterInteractive"
/>
)}For excluding office or VPN IPs in production, configure that in the dashboard under Settings → Exclusions rather than at the script level — keeps the change reversible without a redeploy.
Common pitfalls
- Tracking dev/preview deployments by accident: Vercel preview deployments run in production builds. Gate the script on VERCEL_ENV === 'production', not just NODE_ENV.
- Putting the script in <head> with strategy=beforeInteractive: Forces analytics into the critical path — bad for LCP. Use afterInteractive (default) unless you have a measurable reason.
- Calling datibase() from a Server Component: There is no window on the server. Move the call into a Client Component or fire it via a webhook from your backend.
- Forgetting to add the analytics origin to CSP: If your CSP is strict, missing connect-src silently drops every event. Test by checking the Network tab for blocked requests.
- Tracking high-cardinality custom event properties: Don't pass user IDs, emails, or full URLs as event properties. Aggregations are computed across these — high cardinality both costs money and leaks identifiers.
Verifying the install
Three quick checks before you ship:
- DevTools → Network: POST to
/api/analytics/pageviewon initial load and on each client-side navigation. - DevTools → Application → Cookies: only strictly-necessary cookies (auth, CSRF) — no analytics cookies.
- Datibase dashboard: the live event stream shows your visit within seconds.
The bottom line
For 95% of Next.js apps, analytics is one <Script> tag in the root layout, one identify call after login, and a CSP entry. The rest of this guide is edge cases: tamper-proof revenue events, Server Component patterns, internal-traffic exclusions.
The reward is a banner-free site, a measurable connection between traffic and revenue, and analytics that loads in under a second on every page — the kind of setup most Next.js indie founders stop noticing once it works.
Ship privacy-first analytics on Next.js today
One <Script> tag, no cookies, no consent banner. Connect Stripe and see revenue by referrer source from day one.
Try Datibase free