Next.js

Zenith is built for TypeScript, and Next.js is the first-class path. npx zenith init detects your router and scaffolds everything; this page explains what it writes so you can do it by hand or understand what's there.

The fastest path is npx zenith init — it writes config/zenith.ts and the dashboard route for your router. The rest of this page is the manual version.

1. Install the package

pnpm add zenith-analytics

Then scaffold the config and the dashboard route for whichever router you're on:

npx zenith init      # writes config/zenith.ts + the dashboard route
npx zenith hash      # generates a bcrypt hash for the client's dashboard password

init detects App Router vs Pages Router and writes the wiring for it. The rest of this page is what it produces, so you can do it by hand or understand what's there.

2. Configure

The config is a normal TypeScript module, and it holds no secrets — every secret is read from process.env at the point of use. That's deliberate: the file is safe to commit, so the shape of your integration lives in code review like everything else, while the values live only in your deployment environment.

import type { ZenithConfig } from "zenith-analytics";

/**
 * The two values below are PUBLIC by design: the site key ships inside the
 * tracking snippet on every page, and only authorizes writing events.
 * The secrets (apiKey — reads analytics; passwordHash + jwtSecret — gate the
 * dashboard) come exclusively from the deployment environment:
 *
 *   ZENITH_API_KEY     — from the Zenith console → Add site
 *   ZENITH_PW_HASH     — bcrypt hash from `npx zenith hash`
 *   ZENITH_JWT_SECRET  — any long random string (`npx zenith init` generates one)
 *
 * Without them the tracker still runs; only the dashboard stays offline.
 */
export const ZENITH_PUBLIC = {
  backendUrl: process.env.ZENITH_URL || "https://zenith.example.com",
  siteKey: process.env.ZENITH_SITE_KEY || "zk_your_public_site_key",
};

export const ZENITH_CONFIG: Partial<ZenithConfig> = {
  ...ZENITH_PUBLIC,
  apiKey: process.env.ZENITH_API_KEY,
  dashboardPath: "/zenith",
  protected: true,
  passwordHash: process.env.ZENITH_PW_HASH,
  jwtSecret: process.env.ZENITH_JWT_SECRET,
  siteDomain: "example.com",
};

/**
 * createZenithRoute validates its config at module load and throws on
 * missing secrets — correct for a production deploy, fatal for a local
 * build without env vars. The route only mounts the real handler when
 * this is true.
 */
export function zenithDashboardReady(): boolean {
  return Boolean(
    ZENITH_CONFIG.apiKey &&
      ZENITH_CONFIG.passwordHash &&
      ZENITH_CONFIG.jwtSecret
  );
}

Two exports, and the split between them is the security model made structural:

  • ZENITH_PUBLICbackendUrl and siteKey. Both end up in your HTML anyway. This is what the tracker gets.
  • ZENITH_CONFIG — the public half plus the three secrets. This is what the dashboard route gets, and it never leaves the server.

dashboardPath is /zenith here, but any path works — the default is /analytics-dashboard. Whatever you choose, the route folder in step 4 has to match it.

3. Add the tracker

The tracker is cookieless and about 1 KB. Drop the component into your root layout and hand it ZENITH_PUBLIC — the public half is all it ever needs:

import { Analytics } from "zenith-analytics/next";

import { ZENITH_PUBLIC } from "@/config/zenith";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics config={ZENITH_PUBLIC} />
      </body>
    </html>
  );
}

Pageviews — including client-side route changes — are tracked automatically. For custom events, see Tracking & events.

A prerendered page reads ZENITH_SITE_KEY at build time, not run time. This layout is prerendered by default, so <Analytics> runs during next build. If the key is only supplied afterwards — at run time — the tracker is baked out of every page and you get no pageviews, a dashboard of zeroes. Set the two public values before the build. Deploying covers exactly where.

By default <Analytics> is lenient about a missing key: if siteKey is absent when it renders, it logs a warning to the build log and renders nothing — a missing key never breaks the page. On a site that must not ship untracked, you can invert that with the required prop:

<Analytics config={ZENITH_PUBLIC} required />

required turns a missing key into a thrown error at render, which fails next build for a prerendered page and turns the build red. Reach for it when shipping without analytics is worse than not shipping at all — the failed build is the signal you would otherwise miss until the dashboard stayed empty. It is opt-in per site; leave it off and the default lenient behaviour stands.

Render it on the server. A layout already is, so the code above is correct. Two reasons converge on that one rule.

The snippet is inlined into your HTML and finds its configuration through document.currentScript, which the browser sets only while executing a script it parsed. A script inserted later by client-side React never runs.

And note it's ZENITH_PUBLIC being passed, not ZENITH_CONFIG. Inside a "use client" component React serializes every field of the object you passed into the browser payload — so handing the full config to a client component would ship apiKey and jwtSecret to every visitor. Passing only the public half means there is nothing there to leak even if someone later moves this code.

The component reads only backendUrl and siteKey, and the site key is public by design.

Not using React, or want to place the tag yourself? trackerScriptProps(ZENITH_PUBLIC) returns the props to spread onto a <script>, and trackerScriptTag(ZENITH_PUBLIC) returns plain HTML.

4. Mount the dashboard

This is the signature: a page on your own domain that shows the client's analytics, same-origin and password-gated. It is a route you create yourself, in your app directory — nothing appears until you do.

App Router

Create this exact file:

app/zenith/[[...zenith]]/route.ts
     └──┬──┘ └─────┬─────┘ └───┬───┘
        │          │           └─ a Route Handler, not page.tsx
        │          └───────────── optional catch-all, literally these brackets
        └──────────────────────── must match dashboardPath, minus the slash

The folder name is not decoration — it has to match dashboardPath. The config above sets dashboardPath: "/zenith", so the folder is app/zenith/. If you set dashboardPath: "/analytics-dashboard", the file is app/analytics-dashboard/[[...zenith]]/route.ts. Get this wrong and the path 404s while the handler sits there working perfectly.

The [[...zenith]] segment is an optional catch-all — double brackets, three dots. It's what makes one file serve both /zenith and every path beneath it (/zenith/api/summary, and so on). A single-bracket [...zenith] would not match the bare path, and the dashboard would 404 at its own front door.

import { createZenithRoute } from "zenith-analytics/next";

import { ZENITH_CONFIG, zenithDashboardReady } from "@/config/zenith";

// Without this, Next could statically render the route at build time and
// serve every visitor the same cached page — fatal for a password gate.
export const dynamic = "force-dynamic";

const notConfigured = () =>
  new Response("Zenith dashboard is not configured on this deployment.", {
    status: 503,
  });

// Secrets present → the real dashboard proxy. Absent (local dev, CI) → a
// plain 503 instead of createZenithRoute's intentional startup throw.
const handlers = zenithDashboardReady()
  ? createZenithRoute(ZENITH_CONFIG)
  : { GET: async () => notConfigured(), POST: async () => notConfigured() };

export const { GET, POST } = handlers;

createZenithRoute validates its config when the module loads and throws if a secret is missing. That's the right behaviour for a production deploy — you want to hear about a missing api key immediately, not when a client opens the page. But it also runs during next build, so a laptop or a CI runner without the env vars would fail the build over a dashboard nobody is looking at. The zenithDashboardReady() guard keeps the strict behaviour where it matters and degrades to a 503 everywhere else. The tracker is unaffected either way — it only needs ZENITH_PUBLIC.

Pages Router

Create pages/api/zenith/[[...zenith]].ts — the same guard, in Pages Router shape:

import { createZenithApiRoute } from "zenith-analytics/next";
import type { NextApiRequest, NextApiResponse } from "next";

import { ZENITH_CONFIG, zenithDashboardReady } from "@/config/zenith";

// The handler reads the password form itself; Next's parser would consume
// the stream before it could.
export const config = { api: { bodyParser: false } };

export default zenithDashboardReady()
  ? createZenithApiRoute(ZENITH_CONFIG)
  : (_req: NextApiRequest, res: NextApiResponse) =>
      res.status(503).send("Zenith dashboard is not configured on this deployment.");

Here the API route lives at /api/zenith, so rewrite your dashboardPath onto it in next.config.js:

module.exports = {
  async rewrites() {
    return [
      { source: "/zenith", destination: "/api/zenith" },
      { source: "/zenith/:path*", destination: "/api/zenith/:path*" },
    ];
  },
};

Both source values must match dashboardPath — same rule as the App Router folder name, expressed as a rewrite instead.

dynamic = "force-dynamic" (App Router) and bodyParser: false (Pages Router) are not optional. The first stops Next caching the password gate; the second lets the handler read the login form. npx zenith init sets both.

5. Custom events

Anywhere in your client bundle:

"use client";
import { track } from "zenith-analytics/client";

export function SignupButton() {
  return <button onClick={() => track("signup", { plan: "pro" })}>Sign up</button>;
}

zenith-analytics/client is browser-safe — it holds no secrets and never throws, so a failed analytics call can't break the page.

Entry points

ImportRunsUse it for
zenith-analyticsServerThe ZenithConfig type, the proxy handler, trackerScriptProps. Handles secrets — never import into browser code.
zenith-analytics/clientBrowsertrack(). No config, no secrets.
zenith-analytics/nextServer<Analytics /> plus the App Router and Pages Router adapters.
zenith-analytics/reactServer<Analytics /> on its own, for a React app that isn't Next.js.

Then set ZENITH_API_KEY, ZENITH_PW_HASH and ZENITH_JWT_SECRET in your deployment environment, open yoursite.com/zenith, and enter the password. Your client sees their analytics — on their own domain.

If you get the 503 instead of the password gate, the guard is doing its job: one of those three variables is missing from that environment.