← Back to blog

How to Add Analytics to a Next.js App (Without Hurting Performance)

How to Add Analytics to a Next.js App (Without Hurting Performance)

Meta Description: Install a cookieless tracker in Next.js with proper SPA route-change handling, no hydration issues, and zero Core Web Vitals impact.

Next.js is fast. Really fast. You've probably spent time optimizing for Core Web Vitals, cutting bundle size, and implementing image optimization. The last thing you need is analytics adding bloat and slowing things down.

The good news: you can add production-grade analytics to Next.js in three lines of code. No hydration mismatches, no performance penalties, no configuration hell.

This guide shows you exactly how to do it—plus how to handle SPA route changes, verify it's working, and troubleshoot if things go sideways.

Why Analytics Can Break Next.js

Next.js runs JavaScript on both the server (during SSR) and the client (during hydration). This dual nature creates edge cases that naive analytics implementations run headfirst into.

The Hydration Problem

When you include analytics code in pages/_app.tsx, it might run differently on the server vs. the client:

  • Server-side: The component renders, and your analytics code runs (generating a unique ID, setting a timestamp, etc.)
  • Client-side: React hydrates the page, and your analytics code runs again, generating a different ID or timestamp

Now your server-rendered HTML doesn't match what the client rendered—hydration mismatch. React throws a warning; performance suffers.

The Route-Change Problem

In a traditional SPA, every route change is visible to the analytics script. In Next.js, which uses file-based routing, route changes happen without a traditional SPA router event.

If your analytics only listens for hashchange or popstate, it'll miss most route transitions in Next.js apps. Every navigation looks like the same page to your tracker.

The Performance Problem

Some analytics scripts:

  • Load synchronously, blocking page rendering
  • Run heavy JavaScript on every pageview
  • Generate cookies or local storage access patterns that violate Lighthouse best practices
  • Cause CLS (Cumulative Layout Shift) by injecting DOM elements

The solution is to choose analytics built for modern web performance.

Copy-Paste Install in 3 Lines

Here's the fastest way to add Statalog (or any lightweight analytics) to Next.js:

Option 1: Using next/script (Recommended)

// pages/_app.tsx (or app/layout.tsx for App Router)
import Script from 'next/script';

export default function App({ Component, pageProps }) {
  return (
    <>
      <Script
        async
        src="https://cdn.statalog.com/st.js"
        data-site="ST-XXXXXXX"
      />
      <Component {...pageProps} />
    </>
  );
}

What's happening:

  • next/script loads the script async, so it doesn't block page rendering
  • data-site="ST-XXXXXXX" tells Statalog which site to track (replace with your actual site ID from your Statalog dashboard)
  • The script automatically detects Next.js route changes and tracks pageviews

That's it. No hydration issues, no configuration, automatic route tracking.

Option 2: Plain Script Tag

If you prefer a regular HTML script tag:

<!-- pages/_document.tsx -->
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html>
      <Head>
        <script
          async
          src="https://cdn.statalog.com/st.js"
          data-site="ST-XXXXXXX"
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

Both approaches work. The next/script approach is cleaner and more consistent with Next.js best practices.

Handling Route Changes in Next.js

The Statalog script automatically detects Next.js route changes. You don't need to do anything special.

However, if you want to track specific route transitions or add custom logic, you can use the Next.js useRouter hook:

// pages/blog/[slug].tsx
import { useRouter } from 'next/router';
import { useEffect } from 'react';

export default function BlogPost() {
  const router = useRouter();

  useEffect(() => {
    // This runs every time the route changes
    console.log('Navigated to:', router.asPath);
  }, [router.asPath]);

  return (
    <article>
      <h1>{/* your content */}</h1>
    </article>
  );
}

For App Router (Next.js 13+):

// app/blog/[slug]/page.tsx
'use client';

import { usePathname } from 'next/navigation';
import { useEffect } from 'react';

export default function BlogPost() {
  const pathname = usePathname();

  useEffect(() => {
    console.log('Navigated to:', pathname);
  }, [pathname]);

  return (
    <article>
      <h1>{/* your content */}</h1>
    </article>
  );
}

The analytics script listens for these route changes automatically—you're just adding custom logging here if you need it.

No Impact on Core Web Vitals

The Statalog script is built specifically to avoid Web Vitals penalties:

2KB Gzipped

The entire script is < 2KB. It doesn't bloat your JavaScript bundle.

Async Loading

Using <Script async> means the script loads in the background. Your First Contentful Paint (FCP) and Largest Contentful Paint (LCP) aren't affected.

No DOM Manipulation

The script doesn't inject elements into the DOM, so no Cumulative Layout Shift (CLS).

First-Party, No Cookies

Statalog is cookieless and first-party by default. No third-party tracking cookies = better privacy, better Lighthouse scores, no GDPR headaches.

Result

Your Core Web Vitals stay green. You get analytics without the performance tax.

Optional: Track Custom Events

Pageview tracking is automatic. But you might want to track specific user interactions—button clicks, form submissions, video plays, etc.

The Statalog script exposes a global function for custom events:

// Track a button click
document.getElementById('signup-button').addEventListener('click', () => {
  window.statalog?.('event', 'Signup Button Clicked', {
    button_location: 'hero',
    experiment: 'variant_b'
  });
});

Or for form submissions:

// Track form submission
const form = document.getElementById('contact-form');
form?.addEventListener('submit', (e) => {
  window.statalog?.('event', 'Contact Form Submitted', {
    email_domain: new URL(e.target.email.value).hostname
  });
  // then submit the form normally
});

The ?. operator is defensive—it only calls the function if window.statalog exists. This prevents errors if the script hasn't loaded yet.

Testing It Works

After adding the script, verify it's tracking:

Check DevTools Network Tab

  1. Open DevTools (F12)
  2. Go to the Network tab
  3. Navigate your Next.js app to a new page
  4. Look for requests to api.statalog.com or cdn.statalog.com
  5. You should see a request with your site ID in the query string

Check the Statalog Dashboard

  1. Log into your Statalog account
  2. Go to your site's dashboard
  3. Check the Live or Real-time report
  4. You should see your own pageviews appearing in real-time

Check Browser Console

The analytics script might log debug info:

// In DevTools console, after the script loads:
console.log(window.statalog); // Should be a function, not undefined

If you see undefined, the script didn't load. Check the Network tab to see if the CDN URL is correct.

Troubleshooting

Problem: Hydration Mismatch Error

Error: "Text content does not match server-rendered HTML"

Cause: Your analytics code ran differently on server vs. client.

Solution:

  • Use next/script with the async attribute (shown above)
  • Don't run analytics code in the component body; only in useEffect or _app.tsx
  • Check that data-site attribute is set correctly

Problem: No Pageviews Recorded

Check:

  1. Is the script loading? Check Network tab in DevTools
  2. Is your site ID correct? Check Statalog dashboard for the exact ID
  3. Are you on localhost? Local traffic is usually filtered; check dashboard settings
  4. Is the analytics dashboard showing other sites' data? Wrong site ID

Solution:

  • Copy the exact site ID from your Statalog dashboard
  • Use next/script instead of a manual script tag
  • Check dashboard filters (Local/Test traffic toggle)

Problem: Routes Not Tracked in SPA Mode

Cause: Next.js App Router or dynamic routes aren't triggering analytics.

Solution:

  • Verify the script is async and loading from the correct CDN
  • Check that route changes are updating the URL (check address bar)
  • Use the manual useRouter() or usePathname() approach above to verify routing works

Problem: Performance Degradation

Check:

  1. Is the script blocking page load? Check Network waterfall in DevTools
  2. Is the script synchronous? It should have async attribute

Solution:

  • Always use <Script async> or add async attribute
  • Don't load analytics synchronously in _document.tsx

Code Example: Full Integration

Here's a complete example of analytics in a Next.js App Router project:

// app/layout.tsx
import type { Metadata } from 'next';
import Script from 'next/script';

export const metadata: Metadata = {
  title: 'My Next.js App',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <Script
          async
          src="https://cdn.statalog.com/st.js"
          data-site="ST-XXXXXXX"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

For Pages Router:

// pages/_app.tsx
import type { AppProps } from 'next/app';
import Script from 'next/script';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Script
        async
        src="https://cdn.statalog.com/st.js"
        data-site="ST-XXXXXXX"
      />
      <Component {...pageProps} />
    </>
  );
}

Both are production-ready. Pick the one matching your Next.js version.

Frequently Asked Questions

Does it slow down my app? No. The 2KB script loads asynchronously and runs in the background. No performance penalty.

Is it safe for SSR? Yes. The script handles both server-rendered and hydrated content correctly. No hydration mismatches.

Does it work with middleware? Yes. The script works independently of Next.js middleware. Middleware doesn't affect analytics.

Can I use environment variables for the site ID? Yes. Replace ST-XXXXXXX with your environment variable:

<Script
  async
  src="https://cdn.statalog.com/st.js"
  data-site={process.env.NEXT_PUBLIC_STATALOG_SITE_ID}
/>

(Use NEXT_PUBLIC_ prefix so it's available in the browser.)

What about API routes? API routes don't generate pageviews. Analytics tracks client-side navigation. If you want to track API usage, use custom events or server-side logging.

Can I disable analytics in development? Yes. Only initialize the script in production:

{process.env.NODE_ENV === 'production' && (
  <Script
    async
    src="https://cdn.statalog.com/st.js"
    data-site="ST-XXXXXXX"
  />
)}

That's it. Three lines of code, no configuration, automatic route tracking, and zero performance impact. Your Next.js app is now production analytics-ready.

Ready to add analytics? Start with the copy-paste code above, verify it's working with DevTools, and check your dashboard for real-time data.

Need more? Check out the full Statalog documentation or performance best practices for Next.js.