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 console | Your client's dashboard | |
|---|---|---|
| Where | zenith.example.com/dashboard/ — your Zenith server | theirsite.com/zenith — their own site |
| Who signs in | You | Your client |
| Credentials | Email + password | A password only, no email |
| Set by | ZENITH_ADMIN_EMAIL / ZENITH_ADMIN_PASSWORD in the server's .env | ZENITH_PW_HASH in that site's environment |
| Sees | Every site you manage | Exactly 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 environmentThe 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.