Adding Privacy-First Analytics to a Next.js 15 Site (App Router + Server Components)
TutorialsApril 26, 202611 min read

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

A working tutorial for wiring privacy-first analytics into Next.js 15 with the App Router — RSC pageviews, client events, edge gotchas, and GDPR config.

Say you ship a Next.js 15.2 upgrade to prod. Pageviews stop firing on route changes about ninety seconds later. The script tag is right there in the layout, the network panel shows the request going out, and the dashboard has a flatline where the afternoon should be.

The natural reaction is to assume the analytics tool is broken. Often it isn't: a Pages Router setup was copied into an App Router project on the assumption that it would just work.

It doesn'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 for exactly that situation. It's a working setup for JustAnalytics on Next.js 15 with the App Router (for 15.2 with React 19), including the parts that commonly bite. The same patterns work for Plausible or Fathom with minor tweaks.

The setup

JustAnalytics' docs tell you to add the tracking script:

<script
  defer
  src="https://justanalytics.app/tracker.js"
  data-site-id="YOUR_SITE_ID">
</script>

Then put it somewhere global. In the Pages Router that meant _app.tsx. In the App Router it goes in the root layout:

// app/layout.tsx
import Script from "next/script";

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

That's all most sites need. App Router navigations don't reload the page, but they change the URL through history.pushState, and the tracker hooks pushState, replaceState and popstate. Each soft navigation is recorded as a pageview without extra code.

Manual pageviews, if you need them

If part of your app changes what the reader sees without changing the URL through the history API, you can fire pageviews yourself from a client component that watches usePathname(). 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;
    window.JustAnalytics?.trackPageview();
  }, [pathname, searchParams]);

  return (
    <Script
      src="https://justanalytics.app/tracker.js"
      data-site-id={siteId}
      strategy="afterInteractive"
    />
  );
}

Two things worth pointing out. First, the tracker already records history-based navigations, so after wiring this up check your dashboard for pageviews showing up twice, and drop the manual call if they do. 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. 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 whole setup.

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.JustAnalytics?.track("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 {
    JustAnalytics?: {
      track: (eventName: string, properties?: Record<string, unknown>) => void;
      trackPageview: () => 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.

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 commonly 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. Events can look like they're sending in dev and still not arrive 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";
import JA from "@justanalyticsapp/node";

// Initialise once with JA.init({ siteId, apiKey, serviceName }) in instrumentation.ts

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

  JA.recordMetric("custom.subscription_activated_mrr", body.mrr, { plan: body.plan });

  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 (for example to Cloudflare Pages or a self-hosted setup), 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.JustAnalytics.track(...) 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. The tracker detects route changes by itself and your useEffect fires another pageview, so drop the manual trackPageview() call.

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 with a Next.js rewrites() rule.

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 events?

At 1M events per month, Vercel Analytics runs around $99/month for their Pro tier with that volume, and you'll likely cross into Enterprise pricing if you also want custom events at that scale. JustAnalytics Pro is $49/month, or $39/month billed annually, for up to 5 sites and 1M events per month with 1 year retention — and that same plan also includes error tracking, APM, session replay, uptime monitoring, and structured logs. If you're already paying for Vercel Pro and you don't need custom events or any of those other surfaces, Vercel Analytics is convenient. Past that, the math gets ugly fast.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts