Domain-native dashboard

The signature of Zenith: your client reads their analytics at theirsite.com/zenith — not a foreign subdomain. Because the proxy runs inside their app, the page is same-origin, the cookie is first-party, and there's no CORS.

The path is yours to pick: it's whatever you set as dashboardPath. This page uses /zenith; the package default is /analytics-dashboard.

Two dashboards, two passwords

Worth separating before anything else, because they are easy to confuse:

Your consoleYour client's dashboard
Wherezenith.example.com/dashboard/ — your Zenith servertheirsite.com/zenith — their own site
Who signs inYouYour client
CredentialsEmail + passwordA password only, no email
Set byZENITH_ADMIN_EMAIL / ZENITH_ADMIN_PASSWORD in the server's .envZENITH_PW_HASH in that site's environment
SeesEvery site you manageExactly one site, read-only

Setting up your own console is covered in Self-hosting. The rest of this page is the client-facing one.

Set it up

Two files. The first is the config, which holds no secrets — it names them and reads them from the environment, so it's safe to commit:

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

// Public by design: both of these ship inside the tracking snippet.
export const ZENITH_PUBLIC = {
  backendUrl: process.env.ZENITH_URL || "https://zenith.example.com",
  siteKey: process.env.ZENITH_SITE_KEY || "zk_your_public_site_key",
};

// The public half plus the three secrets. Server-side only.
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: "theirsite.com",
};

// createZenithRoute throws at module load when a secret is missing.
export function zenithDashboardReady(): boolean {
  return Boolean(
    ZENITH_CONFIG.apiKey &&
      ZENITH_CONFIG.passwordHash &&
      ZENITH_CONFIG.jwtSecret
  );
}

The second is the route itself — the dashboard does not exist until you create this file in the app directory:

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 must match dashboardPath. dashboardPath: "/zenith"app/zenith/. Change one and you must change the other, or the URL 404s while the handler underneath works perfectly. [[...zenith]] is an optional catch-all — double brackets — which is what lets one file serve both /zenith and every path beneath it.

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 at module load and throws when a secret is missing. In production that's exactly right — you want to hear about a missing api key at deploy time, not from your client. But it runs during next build too, so an unconfigured laptop or CI runner would fail the whole build over a dashboard nobody is looking at. The guard keeps the strictness in production and returns a 503 everywhere else. Tracking is unaffected: <Analytics /> only ever receives ZENITH_PUBLIC.

The Next.js page has the Pages Router equivalent.

If your app sends a Content-Security-Policy

The page your route serves is a shell: it loads the dashboard's stylesheet, script and fonts from your Zenith service, and calls back to your own origin for the data. A CSP that doesn't know about your Zenith host will block some of that.

script-src  'self' https://zenith.example.com;
style-src   'self' 'unsafe-inline' https://zenith.example.com;
font-src    'self' data: https://zenith.example.com;
connect-src 'self' https://zenith.example.com;

Allowing only script-src is the trap. The dashboard boots, loads real data and shows the right numbers — with no styling at all, because the stylesheet was blocked and nothing else was. It looks like a broken build rather than a policy decision, and the browser console is the only place that says otherwise. font-src fails the same way, quieter.

Next.js usually sets this in headers() in next.config.js or in middleware. If a proxy or CDN in front of your app adds its own policy, the two intersect — the strictest wins, so check the header on the response rather than the file you edited.

The password gate

With protected: true, the dashboard shows a single centered password card before anything else. Generate the hash — never store a plaintext password:

npx zenith hash
# → set the result as ZENITH_PW_HASH in the site's environment

The password is verified against that bcrypt hash in your app. Zenith never learns it. A correct password mints a signed, HttpOnly, first-party session cookie that lasts sessionTtl (12 hours by default).

To publish the dashboard with no gate, set protected: false. To change the password, run npx zenith hash again and replace ZENITH_PW_HASH in the environment — no code change, no redeploy of the config.

Why it's safe

Every data request the page makes is same-origin against your own server. The proxy re-authenticates it, then forwards it to Zenith with the site's secret apiKey attached server-side:

  • The browser never sees the api key.
  • The key names the site, so a tampered ?site= parameter changes nothing.
  • There's no site switcher and no way to reach another client's data — the owner sees exactly one site, read-only.

Hand it to your client

You send them two things:

Your analytics are at https://theirsite.com/zenith Password: (whatever you hashed)

That's the whole handoff. No account, no third-party subdomain, no Zenith branding — it reads as a native page of their own site. If you also set their owner email, they get a monthly report without ever opening the dashboard.