TutorialsApril 26, 202611 min read

Adding Privacy-First Analytics to a Next.js 15 Site (App Router + Server Components)

Adding Privacy-First Analytics to a Next.js 15 Site (App Router + Server Components)

Last week I shipped a Next.js 15 upgrade to prod with the App Router and immediately broke our analytics. Pageviews stopped firing on route changes. The script tag was there, the network panel showed the request going out, but the dashboard had a flatline where Tuesday afternoon should have been.

The problem wasn't the analytics tool. The problem was that I'd copied a Pages Router setup into an App Router project and assumed it would just work. It didn't. The App Router treats navigation differently, Server Components don't run in the browser, and the script-tag-in-_app.tsx pattern that's been recommended for years doesn't translate.

So this post is the writeup I wish I'd had on Monday. It's a working setup for JustAnalytics on Next.js 15 with the App Router, including the parts that bit me. Same patterns work for Plausible or Fathom with minor tweaks, but the code shown is for JustAnalytics.

Setup: the obvious way (and why it's wrong)

Most "add analytics to Next.js" tutorials tell you to do this:

npm install @justanalytics/next

Then drop a script tag somewhere global. In Pages Router that meant _app.tsx. The natural App Router translation is the root layout:

// app/layout.tsx — DON'T DO THIS
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Script
          src="https://cdn.justanalytics.app/script.js"
          data-site="your-site-id"
          strategy="afterInteractive"
        />
        {children}
      </body>
    </html>
  );
}

This loads the script. It even fires the first pageview correctly. Then you click an internal link, the App Router does a soft navigation via the React Server Components payload, the URL changes, and... nothing. No new pageview gets recorded.

The reason: App Router navigation doesn't trigger a full page load. The script's auto-pageview detection — which usually listens to popstate or hooks history.pushState — either misses the RSC navigation entirely or fires inconsistently depending on which transition is happening. You need to wire pageviews up explicitly.

Tracking page views the App Router way

The fix is a small client component that listens to usePathname() and fires a pageview whenever the path changes. Then you mount it once in the root layout.

// app/_components/Analytics.tsx
"use client";

import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import Script from "next/script";

export function Analytics({ siteId }: { siteId: string }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    if (typeof window === "undefined") return;
    const url = pathname + (searchParams?.toString() ? `?${searchParams}` : "");
    // @ts-expect-error — global injected by the script
    window.ja?.("pageview", { url });
  }, [pathname, searchParams]);

  return (
    <Script
      src="https://cdn.justanalytics.app/script.js"
      data-site={siteId}
      data-auto-pageview="false"
      strategy="afterInteractive"
    />
  );
}

Two things worth pointing out. First, data-auto-pageview="false" disables the script's built-in detection — we're handling it manually so we don't double-count. Second, we depend on both pathname and searchParams because filter-driven pages (/products?category=mugs) should count as separate views. If you don't care about query strings, drop the searchParams dependency.

Mount it in the root layout:

// app/layout.tsx
import { Suspense } from "react";
import { Analytics } from "./_components/Analytics";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Suspense fallback={null}>
          <Analytics siteId={process.env.NEXT_PUBLIC_JA_SITE_ID!} />
        </Suspense>
      </body>
    </html>
  );
}

The Suspense boundary matters. useSearchParams() triggers client-side rendering of the entire subtree under it during static generation, and without a boundary, Next.js will bail your whole layout out of static rendering. Wrapping just the analytics component contains the damage.

You'll also want this in your .env.local and on whatever host you're deploying to:

NEXT_PUBLIC_JA_SITE_ID=your-site-id-here

That's the minimum viable setup. Pageviews now fire correctly on every soft navigation, and the cost is one tiny client component.

Custom events from client components

Pageviews are the easy part. Custom events — button clicks, form submits, signup completions — are where analytics actually earns its keep.

Here's a checkout button that fires a purchase_started event:

// app/checkout/CheckoutButton.tsx
"use client";

import { useTransition } from "react";

export function CheckoutButton({ planId, price }: { planId: string; price: number }) {
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    // @ts-expect-error — global from JustAnalytics script
    window.ja?.("event", "purchase_started", {
      plan_id: planId,
      price_usd: price,
    });

    startTransition(() => {
      // your existing checkout flow
    });
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? "Redirecting..." : "Start checkout"}
    </button>
  );
}

If you want type safety instead of @ts-expect-error everywhere, declare the global in a types/global.d.ts:

// types/global.d.ts
declare global {
  interface Window {
    ja?: (action: "pageview" | "event", ...args: unknown[]) => void;
  }
}

export {};

A note on what data you can send: keep it non-personal. Plan IDs, prices, page categories, A/B test variants — fine. Email addresses, names, anything that identifies a specific person — don't. The whole point of cookieless analytics is that you can skip the consent banner, and the second you start sending PII, you've reintroduced the GDPR conversation you were trying to avoid.

Edge runtime gotchas

Next.js 15 lets you opt routes into the edge runtime with export const runtime = "edge". It's fast, it's cheap, and it does not have the same fetch semantics as Node.

Three things I've seen break:

  1. Outbound tracking from middleware fails silently. If you try to fire a server-side event from middleware.ts (which always runs on the edge), some hosts will time out or drop the request before it completes. The edge runtime has aggressive limits on outbound work that doesn't block the response. Don't track from middleware.

  2. headers() and cookies() behave differently in edge route handlers. This isn't strictly an analytics problem but it'll trip you up if you're trying to read the user's referrer or country code for an event. Use request.headers directly in edge handlers.

  3. Node's crypto.randomUUID() works on edge but Node-specific imports don't. If your analytics wrapper depends on anything from node:crypto or node:buffer, the edge build will fail.

The pragmatic answer: do client-side tracking from the browser (which doesn't care about your runtime) and server-side tracking only from Node.js route handlers. Here's a Node route handler that records a server-side conversion:

// app/api/conversions/route.ts
export const runtime = "nodejs"; // explicit, even though it's the default

import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const body = await req.json();

  await fetch("https://api.justanalytics.app/v1/events", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.JA_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      site_id: process.env.NEXT_PUBLIC_JA_SITE_ID,
      event: "subscription_activated",
      properties: { plan: body.plan, mrr: body.mrr },
    }),
  });

  return Response.json({ ok: true });
}

Server-side events are useful for things the browser can't see — Stripe webhooks confirming a payment, a background job marking a trial as converted, or anything that happens after the user closed the tab.

GDPR config (no banner needed)

JustAnalytics is cookieless by default and doesn't store personal identifiers, so you fall outside the ePrivacy Directive's consent requirement. You don't need a cookie banner.

But there's still a small bit of config worth getting right. In your dashboard settings, make sure these are on:

  • EU-only data processing (Frankfurt region) — keeps data out of the US entirely
  • IP anonymization — strips the last octet before the IP touches our pipeline
  • Do Not Track respect — visitors with DNT enabled aren't tracked at all

Then add a single line to your privacy policy. Something like:

We use JustAnalytics, a privacy-first analytics service, to count pageviews and aggregate visitor patterns. JustAnalytics does not use cookies, does not collect personal information, and processes data in the European Union.

That's it. No banner, no consent state to manage, no Tag Manager wrapper, no opt-out cookie to remember. The same approach works for Plausible and Fathom — the legal position is identical across the cookieless analytics tools.

If you're running paid acquisition alongside this, the analytics stack pairs naturally with ClickzProtect for click fraud detection — wire your ad-click events into JustAnalytics and you can spot fraud patterns by source and time of day. And teams running multi-account workflows often pair it with JustBrowser when they need to verify their tracking behaves correctly across separate browser profiles.

Comparing to Vercel Analytics

If you're on Vercel, you've probably seen the "Analytics" tab in your dashboard. It's not bad. It's also not free at any meaningful scale.

Vercel Analytics is fine if you're already on Vercel — but at $99/mo for 100k events on the Pro plan, it gets expensive fast. The free tier covers small projects, but the moment your traffic crosses about 25k pageviews a month, you're paying. And custom events are billed separately.

The other thing worth knowing: Vercel Analytics is built on top of their edge network, which means it's fast and tightly integrated, but it's also locked to Vercel. If you ever move off (we've talked to teams that switched to Cloudflare Pages or self-hosted Coolify after a pricing change), your analytics history doesn't come with you.

JustAnalytics, Plausible, and Fathom all run independently of your hosting platform. We've covered the trade-offs between the three in our Plausible vs Fathom comparison — short version: all three are within 20% of each other on price and features, and Vercel Analytics is the outlier on both axes.

Common errors and how to fix them

"Cannot find name 'window'" in TypeScript. You're trying to call window.ja(...) from a Server Component. Move the call into a "use client" component, or guard it with if (typeof window !== "undefined") if it's truly conditional.

Pageviews fire twice on initial load. You forgot data-auto-pageview="false" on the Script tag, so the script is firing one and your useEffect is firing another. Disable auto-pageview and let the effect handle it.

Events fire in dev but not in prod. Check that NEXT_PUBLIC_JA_SITE_ID is actually set in your production environment. The NEXT_PUBLIC_ prefix is required — without it, Next.js won't expose the variable to the client bundle.

useSearchParams() warning during build. Wrap the analytics component in <Suspense>. Without it, the entire layout opts out of static rendering and your build slows down considerably.

Adblockers are eating your script. Some adblock lists flag any third-party analytics, including privacy-first ones. The fix is to proxy the script through your own domain — JustAnalytics docs cover this with a Next.js rewrite rule that points /ja/script.js at the CDN. Slightly more setup, dramatically better tracking accuracy.

That's the full setup. The whole thing is maybe 40 lines of code, runs in production at sites doing millions of pageviews a month, and costs nothing to start. More posts like this on the JustAnalytics blog.

Frequently Asked Questions

Does this work with the Pages Router too?

Yes. The Pages Router is actually simpler — drop the script in pages/_app.tsx or pages/_document.tsx and you're done. The App Router guidance in this post (Server Components, route handlers, edge runtime) doesn't apply because Pages Router doesn't use any of those primitives. If you're on Pages Router, use the standard <Script strategy='afterInteractive'> pattern and skip the RSC sections.

Can I use this on Vercel's edge runtime?

Yes, with one caveat: don't try to send tracking events from edge middleware. The edge runtime doesn't have full Node.js fetch semantics for some outbound calls, and you'll occasionally see timeouts. Use middleware for routing and headers, then track from a Server Component or Route Handler running on the Node.js runtime. For client-side events, the edge has nothing to do with it — the browser fires those directly.

No. JustAnalytics is cookieless and doesn't store personal identifiers, so you fall outside the ePrivacy Directive's consent requirement and outside GDPR's processing-of-personal-data scope. You should still mention analytics in your privacy policy (good practice, and required in some jurisdictions), but no consent pop-up is needed. This is the same legal position Plausible and Fathom take.

How does pricing compare to Vercel Analytics at 1M pageviews?

At 1M pageviews per month, Vercel Analytics runs around $99/month for their Pro tier with that volume of events, and you'll likely cross into Enterprise pricing if you also want custom events at that scale. JustAnalytics is $28/month at 1M pageviews with unlimited custom events included. If you're already paying for Vercel Pro and you don't need custom events, Vercel Analytics is convenient. Past that, the math gets ugly fast.

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts