Add analytics to your Next.js app

Lightweight, privacy-first analytics for Next.js. Drop in a script tag, write four lines of code, and ship in under ten minutes.

The Payoff

What you get

After setup:

  • Page views on every route change. App Router or Pages Router.
  • Custom events from any Client Component.
  • No cookies, no consent banner, no measurable Core Web Vitals impact.
  • Live data, updated as events arrive.

No Janus account yet? Add Janus in 5 minutes to grab a free API key. The free tier covers 10,000 events a month.

These steps cover the App Router. On the Pages Router? Scroll to the Pages Router section near the end of this guide.

Step 1

Add the script in your root layout

Use next/script with the afterInteractive strategy. Initialize with trackSPANavigation: false; the App Router emits its own route-change events, and Step 2 catches those. This key ships to the browser, so use an Ingest key — the default scope in Settings. Ingest keys can only send events. A Full access key here would let anyone who views source read your analytics and create more keys.

app/layout.tsx
import Script from 'next/script';
import { Analytics } from './components/Analytics';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Script
src="https://addjanus.ca/janus.js"
strategy="afterInteractive"
/>
<Script id="janus-init" strategy="afterInteractive">
{`
Janus.init('jns_your_api_key', {
trackSPANavigation: false
});
`}
</Script>
<Analytics />
</body>
</html>
);
}

Step 2

Track route changes from a Client Component

Server Components can’t reach window. Drop this Client Component into your root layout. It watches usePathname and fires Janus.page() on each route change.

app/components/Analytics.tsx
'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
declare global {
interface Window {
Janus?: {
page: () => void;
track: (name: string, props?: Record<string, unknown>) => void;
};
}
}
export function Analytics() {
const pathname = usePathname();
useEffect(() => {
window.Janus?.page();
}, [pathname]);
return null;
}

Step 3

Track signups, clicks, and form submits

Custom events fire from any Client Component. One method, two arguments: a name and an optional properties object.

components/SignupButton.tsx
'use client';
export function SignupButton() {
return (
<button
onClick={() =>
window.Janus?.track('signup_click', {
plan: 'pro',
location: 'hero'
})
}
>
Sign up
</button>
);
}

Tracking from a Server Action or Route Handler? Server-side calls bypass the SDK. Use the HTTP API with your API key, or pass the data to a Client Component that calls Janus.track.

Verify

See your first real user

Within a minute or two of your first visit, your dashboard starts to fill in. Here's what it looks like with a handful of real page views and one custom event:

dashboard previewlast 24 hours
Page views
47
Unique visitors
23
Events
12
Top pages
/18
/about11
/projects/weather-app9
/blog/building-in-public6
/contact3
Recent events
signup_click/
2m ago
page_view/about
4m ago
project_view/projects/weather-app
7m ago

No tutorial needed. You can tell what's happening with your app at a glance.

No configuration, no funnel setup, no goal creation. Open the dashboard and it already makes sense.

Common Gotchas

Things that catch people

  • App Router with trackSPANavigation: true double-counts. The SDK fires on replaceState, then the usePathname effect fires again. Set it to false.
  • Calling Janus.track from a Server Component throws. Mark the component 'use client' or use the HTTP API.
  • React Strict Mode double-invokes effects in development. Two page events per route under next dev is expected. next start against a production build fires one.
  • A Full access key in NEXT_PUBLIC_ or inline in a component is readable by every visitor. Only Ingest keys belong in client code; keep full-access keys in server-only environment variables.

Pages Router

On the Pages Router?

Skip Steps 1 and 2. Use this instead. The SDK’s default SPA tracking handles route changes via the History API.

pages/
// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html>
<Head>
<script
src="https://addjanus.ca/janus.js"
defer
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { useEffect } from 'react';
export default function App({ Component, pageProps }: AppProps) {
useEffect(() => {
window.Janus?.init('jns_your_api_key');
}, []);
return <Component {...pageProps} />;
}

The Point

No bundler, no boilerplate

Janus is a hosted script. The whole integration is one Script tag, one init call, and one Client Component.

  • No npm install. The SDK loads from our CDN at runtime.
  • No bundle impact. The loader is under 1 KB.
  • No cookie banner. Janus doesn’t set cookies.
  • No fingerprinting. No third-party data sharing.

Once events are flowing, connect Janus to your AI coding agent via MCP and ask about your traffic from the editor. Migrating from Google Analytics? Replace GA on a side project covers porting custom events and removing gtag.js. Working in another framework? See the framework guides for Nuxt, Remix, Astro, and SvelteKit.

Frequently Asked Questions

Does Janus work with the App Router and Server Components?

Yes. Load the SDK via next/script in your root layout, then call Janus.page() from a Client Component that watches usePathname. Server Components stay untouched. Only the Analytics component needs the 'use client' directive.

Why disable trackSPANavigation on the App Router?

The App Router fires replaceState on every navigation. Janus auto-detects those calls. If SPA tracking stays on while the usePathname effect also fires, each route change counts twice. Disabling SPA tracking and calling Janus.page from the Client Component gives you exactly one event per route.

Can I track events from a Server Action?

Not via the SDK. There is no window on the server. Either return the data to a Client Component that calls Janus.track, or call the Janus HTTP API directly from your server with an API key.

Will Janus affect my Core Web Vitals or bundle size?

No. The loader is under 1 KB and loads asynchronously after the page becomes interactive. It is not bundled with your app. The SDK loads from the Janus CDN at runtime, so your build output is unchanged.

Do I need a different setup for Vercel vs self-hosted Next.js?

No. The integration is identical. Janus is a hosted script, so it works the same wherever your Next.js app runs.

I see two page events in development. Is that a bug?

No. React Strict Mode double-invokes effects in development to surface concurrency bugs. Production fires each event once. Verify by running next start against a production build.

Does this work with the Pages Router?

Yes. The Pages Router is simpler. One script tag in _document.tsx and one init call in _app.tsx. The SDK default SPA tracking handles route changes via the History API.

Wire it up in under ten minutes.

Free tier. No credit card. No bundler config.

Get started with Janus