Adding Privacy-First Analytics to a Next.js 15 Site (App Router + Server Components)
TutorialsApril 26, 202612 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 Tuesday I shipped our Next.js 15.2 upgrade to prod. Pageviews stopped firing on route changes about ninety seconds later. The script tag was right there in the layout, the network panel showed the request going out, and the dashboard had a flatline where Tuesday afternoon should have been.

So I did what any reasonable person does — I assumed the analytics tool was broken. It wasn't. 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. The script-tag-in-_app.tsx pattern that's been getting recommended for years doesn't translate. So this post is the writeup I wish I'd had on Monday morning, before I lost half a day to it. It's a working setup for JustAnalytics on Next.js 15 with the App Router (tested on 15.2 with React 19), including the parts that bit me. The same patterns work for Plausible or Fathom with minor tweaks.

The naive setup (and why it breaks)

Most "add analytics to Next.js" tutorials tell you to install the package:

npm install @justanalytics/next@^2.4.0

Then drop a script tag somewhere global. In the Pages Router that meant _app.tsx. The natural-looking 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 RSC payload, the URL changes, and... crickets. No 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. I spent two hours chasing a hydration mismatch warning before realising it was unrelated and the actual fix was to wire pageviews up by hand.

Tracking pageviews the App Router way

The fix is small. A client component that subscribes to usePathname() and fires a pageview whenever the path changes. Mount it once in the root layout. Done.

// 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}` : "");
    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. The first time I shipped this without that flag, every pageview registered twice and our bounce rate dropped to 4% overnight. Looked great until someone in Slack asked why. 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, wrapped in a Suspense boundary:

// 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 more than it looks. 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 — which is exactly what I did the first time. The build went from forty seconds to four minutes. 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 fire correctly on every soft navigation. Honestly the whole thing should be a codemod by now.

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() {
    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 a // @ts-expect-error comment in twenty places, declare the global once in a types/global.d.ts:

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

export {};

Quick note on what data you can send: keep it non-personal. Plan IDs, prices, page categories, A/B test variants — fine. Email addresses, names, phone numbers — don't. The point of cookieless analytics is that you skip the consent banner, and the second you start sending PII you've quietly reintroduced the GDPR conversation you were trying to avoid. I've watched two startups make that mistake. Both ended up paying lawyers more than they ever spent on analytics.

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. It does not have the same fetch semantics as Node, and the docs are politely vague about which calls are subject to which limits.

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. I lost a Friday to this — events looked like they were sending in dev, and just weren't arriving in prod. Don't track from middleware.

  2. headers() and cookies() from next/headers behave a little 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 and you'll save yourself the headache.

  3. Node-specific imports break the edge build. crypto.randomUUID() works on edge, but anything from node:crypto or node:buffer will fail at build time with a fairly cryptic error. If your analytics wrapper depends on those, you're not running on the edge.

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 earn their keep on the things the browser can't see — Stripe webhooks confirming a payment, a background job marking a trial as converted, anything that happens after the user closed the tab. Browser-only conversion tracking will lie to you about your actual revenue, and not in your favour.

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, and it's been that way since the EDPB clarified the cookieless-counter exception in 2021.

If you're running paid acquisition alongside this, the stack pairs well 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. We've written up how to read the conversion drop-offs fraud usually causes, worth a skim if your CPA jumped recently. Teams running multi-account workflows often pair the setup with JustBrowser to verify tracking behaves correctly across separate browser profiles.

Comparing to Vercel Analytics

If you're on Vercel, you've probably noticed 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. Custom events are billed separately. The pricing page is, let's say, optimistically laid out.

The other thing worth knowing: Vercel Analytics is built on top of their edge network, which means it's fast and tightly integrated — and locked to their platform. If you ever move off (we've talked to teams that switched to Cloudflare Pages or self-hosted on Coolify after the May 2024 pricing change), your analytics history doesn't come with you. JustAnalytics, Plausible, and Fathom all run independent of your host.

We've covered the trade-offs between the three in our Plausible vs Fathom comparison — short version: all three are within about 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 fires one and your useEffect fires another. (See above. I have done this. More than once.)

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. Vercel and Netlify both have a habit of letting you set environment variables without the prefix and then quietly stripping them at build time.

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 the privacy-first ones. Fix is to proxy the script through your own domain — JustAnalytics docs cover this with a Next.js rewrites() rule that points /ja/script.js at the CDN. We saw a 12% lift in tracked pageviews on a B2B SaaS site after proxying. Not nothing.

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