Analytics Dashboard

Plan for an ANALYTICS.1 dashboard

What the dashboard should be

Treat the dashboard as the control plane for an analytics namespace — not just “site analytics.”

The winning framing is:

  • ANALYTICS.1 = the Web3-native analytics endpoint

  • Web2 twin (e.g., analytics.website / analytics1.site) = onboarding + HTTPS access

  • The dashboard is where operators, partners, and subdomain tenants monitor performance, campaigns, and revenue across your ecosystem.

Best use for the dashboard

The most valuable “best use” is a multi-tenant analytics console that supports:

  1. The WEB-3 network
    Track each TLD site / landing page / partner page as a “tenant,” including:

  • traffic and conversion funnels

  • partner campaign performance

  • lead submissions / signups

  • lease inquiries / purchases

  • affiliate/referrer attribution

  1. On-chain + off-chain in one view (dual stack)
    Combine:

  • Web events (page views, CTA clicks, form submits)

  • Campaign metadata (UTM, referrer, partner)

  • On-chain signals (mints, leases, payments, wallet connects) if applicable

  1. A “generic endpoint” model
    What this offers: 

  • tenant.analytics.1 as a namespace concept (even if it’s routed today)

  • api.analytics.1 for ingest/query endpoints (Web2-hosted but branded as the analytics root)

  • a “verified analytics” directory later (optional)

Phased build

Phase 1 (MVP, 1–2 weeks):

  • Dashboard UI

  • Event tracking snippet (1 JS file)

  • Ingest endpoint (POST events)

  • Basic reports: KPIs, time series, top pages, top referrers, campaign performance, conversions

Phase 2 (Real product, 2–6 weeks):

  • Multi-tenant RBAC (admin / partner / viewer)

  • Alerts (spikes, drops, bot traffic)

  • Export + API keys per tenant

  • Consent + privacy settings (GDPR-friendly)

Phase 3 (Web3-native differentiator):

  • Wallet-based access (Sign-In With Ethereum)

  • On-chain indexing integration (The Graph / RPC indexing)

  • Proof/attestation of campaign metrics if you want an auditable layer


Architecture (simple and scalable)

  • Frontend: React + Tailwind + Recharts (fast to ship, good visuals)

  • Backend API: Node/Express (ingest + query)

  • Storage: start with SQLite/Postgres; scale to ClickHouse later

  • Event model: tenant_id, event, url, referrer, utm_*, wallet, timestamp


Production-ready starter code (Frontend + Backend)

Below is a working starter that can be can run in minutes:

  • A modern analytics dashboard UI (multi-tenant selector, date range, KPIs, charts, table)

  • A simple backend with:

    • POST /api/events (ingest)

    • GET /api/metrics (dashboard query)     

    • (Real sources can be wired later – (PostHog, Plausible, GA4 export, ClickHouse, Subgraphs, etc.).


1) Backend (Node + Express) — server.js

 
// server.js
// Minimal analytics ingest + metrics API (in-memory store for MVP).
// Replace the in-memory array with SQLite/Postgres/ClickHouse as you scale.

import express from "express";
import cors from "cors";

const app = express();
app.use(cors());
app.use(express.json({ limit: "1mb" }));

/**
* Event schema (MVP):
* {
* tenantId: "analytics.1" | "scotland.web-3" | ...
* type: "pageview" | "click" | "signup" | "purchase" | ...
* url: "/pricing"
* referrer: "https://example.com"
* utm: { source, medium, campaign, content, term }
* wallet: "0x..." (optional)
* ts: number (ms epoch)
* }
*/

const events = [];

// Helpers
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
const parseMs = (v, fallback) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
const dayKey = (ms) => {
const d = new Date(ms);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
};

app.post("/api/events", (req, res) => {
const body = req.body || {};
const now = Date.now();

const tenantId = String(body.tenantId || "analytics.1").slice(0, 80);
const type = String(body.type || "pageview").slice(0, 40);
const url = String(body.url || "/").slice(0, 300);
const referrer = String(body.referrer || "").slice(0, 300);
const utm = body.utm && typeof body.utm === "object" ? body.utm : {};
const wallet = body.wallet ? String(body.wallet).slice(0, 120) : null;
const ts = clamp(parseMs(body.ts, now), now - 1000 * 60 * 60 * 24 * 365, now + 1000 * 60);

events.push({
tenantId,
type,
url,
referrer,
utm: {
source: String(utm.source || ""),
medium: String(utm.medium || ""),
campaign: String(utm.campaign || ""),
content: String(utm.content || ""),
term: String(utm.term || ""),
},
wallet,
ts,
});

// Keep memory bounded for MVP
if (events.length > 200000) events.splice(0, events.length - 200000);

res.json({ ok: true });
});

// Compute metrics for a tenant and date range.
app.get("/api/metrics", (req, res) => {
const now = Date.now();
const tenantId = String(req.query.tenantId || "analytics.1");
const from = parseMs(req.query.from, now - 1000 * 60 * 60 * 24 * 30);
const to = parseMs(req.query.to, now);

const filtered = events.filter(
(e) => e.tenantId === tenantId && e.ts >= from && e.ts <= to
);

// KPIs
const pageviews = filtered.filter((e) => e.type === "pageview").length;
const clicks = filtered.filter((e) => e.type === "click").length;
const signups = filtered.filter((e) => e.type === "signup").length;
const purchases = filtered.filter((e) => e.type === "purchase").length;

const conversionRate = pageviews > 0 ? (signups / pageviews) : 0;

// Time series (daily)
const byDay = new Map();
for (const e of filtered) {
const k = dayKey(e.ts);
if (!byDay.has(k)) byDay.set(k, { day: k, pageviews: 0, signups: 0, purchases: 0 });
const row = byDay.get(k);
if (e.type === "pageview") row.pageviews += 1;
if (e.type === "signup") row.signups += 1;
if (e.type === "purchase") row.purchases += 1;
}
const series = Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));

// Top pages
const pageCounts = new Map();
for (const e of filtered) {
if (e.type !== "pageview") continue;
pageCounts.set(e.url, (pageCounts.get(e.url) || 0) + 1);
}
const topPages = Array.from(pageCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([url, count]) => ({ url, count }));

// Top referrers
const refCounts = new Map();
for (const e of filtered) {
if (e.type !== "pageview") continue;
const key = e.referrer || "(direct)";
refCounts.set(key, (refCounts.get(key) || 0) + 1);
}
const topReferrers = Array.from(refCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([referrer, count]) => ({ referrer, count }));

// Campaign performance
const campCounts = new Map();
for (const e of filtered) {
const c = (e.utm?.campaign || "").trim() || "(none)";
campCounts.set(c, (campCounts.get(c) || 0) + 1);
}
const campaigns = Array.from(campCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([campaign, events]) => ({ campaign, events }));

res.json({
ok: true,
tenantId,
range: { from, to },
kpis: { pageviews, clicks, signups, purchases, conversionRate },
series,
topPages,
topReferrers,
campaigns,
});
});

const port = process.env.PORT || 8787;
app.listen(port, () => console.log(`API running on http://localhost:${port}`));

Run backend

 
mkdir analytics-1 && cd analytics-1
npm init -y
npm i express cors
# enable ESM:
node -e "const p=require('./package.json'); p.type='module'; require('fs').writeFileSync('package.json', JSON.stringify(p,null,2));"
node server.js

2) Frontend (React + Tailwind + Recharts) — App.jsx

This is a complete dashboard UI. It expects the backend above on http://localhost:8787.

 
// App.jsx
import React, { useEffect, useMemo, useState } from "react";
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
BarChart, Bar, Legend
} from "recharts";

// Simple helpers
const fmtInt = (n) => new Intl.NumberFormat().format(n ?? 0);
const fmtPct = (n) => `${((n ?? 0) * 100).toFixed(1)}%`;
const daysAgoMs = (d) => Date.now() - d * 24 * 60 * 60 * 1000;

function Card({ title, value, subtitle }) {
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<div className="text-sm text-gray-500">{title}</div>
<div className="mt-1 text-2xl font-semibold text-gray-900">{value}</div>
{subtitle ? <div className="mt-1 text-xs text-gray-500">{subtitle}</div> : null}
</div>
);
}

function Table({ title, columns, rows, rowKey }) {
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<div className="mb-3 text-sm font-semibold text-gray-900">{title}</div>
<div className="overflow-auto">
<table className="min-w-full text-sm">
<thead className="text-left text-gray-500">
<tr>
{columns.map((c) => (
<th key={c.key} className="border-b px-2 py-2 font-medium">
{c.label}
</th>
))}
</tr>
</thead>
<tbody className="text-gray-800">
{rows.map((r) => (
<tr key={rowKey(r)} className="hover:bg-gray-50">
{columns.map((c) => (
<td key={c.key} className="border-b px-2 py-2">
{c.render ? c.render(r) : r[c.key]}
</td>
))}
</tr>
))}
{rows.length === 0 ? (
<tr>
<td className="px-2 py-6 text-gray-500" colSpan={columns.length}>
No data in this range.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
);
}

export default function App() {
const [tenantId, setTenantId] = useState("analytics.1");
const [from, setFrom] = useState(daysAgoMs(30));
const [to, setTo] = useState(Date.now());
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState("");

const apiBase = "http://localhost:8787";

const tenants = useMemo(
() => [
"analytics.1",
"web-3.scotland",
"web-3.gbr",
"avgas.1",
"lettings.1",
"zipcode.1",
],
[]
);

async function load() {
setLoading(true);
setError("");
try {
const url = new URL(`${apiBase}/api/metrics`);
url.searchParams.set("tenantId", tenantId);
url.searchParams.set("from", String(from));
url.searchParams.set("to", String(to));
const res = await fetch(url.toString());
const json = await res.json();
if (!json.ok) throw new Error("API error");
setData(json);
} catch (e) {
setError(e?.message || "Failed to load");
} finally {
setLoading(false);
}
}

useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantId]);

// Quick local event simulator so you can see the dashboard populate
async function simulateTraffic() {
const pages = ["/", "/pricing", "/partners", "/docs", "/contact"];
const refs = ["(direct)", "https://x.com", "https://google.com", "https://reddit.com", "https://example.com"];
const campaigns = ["(none)", "winter-launch", "partner-a", "affiliate-42"];

const now = Date.now();
const n = 250 + Math.floor(Math.random() * 400);

for (let i = 0; i < n; i++) {
const ts = now - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 21);
const roll = Math.random();
let type = "pageview";
if (roll > 0.82) type = "click";
if (roll > 0.92) type = "signup";
if (roll > 0.985) type = "purchase";

const payload = {
tenantId,
type,
url: pages[Math.floor(Math.random() * pages.length)],
referrer: refs[Math.floor(Math.random() * refs.length)] === "(direct)" ? "" : refs[Math.floor(Math.random() * refs.length)],
utm: { campaign: campaigns[Math.floor(Math.random() * campaigns.length)] },
ts,
};

await fetch(`${apiBase}/api/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}

await load();
}

const k = data?.kpis || {};
const series = data?.series || [];

return (
<div className="min-h-screen bg-gray-50">
<header className="border-b bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<div>
<div className="text-lg font-semibold text-gray-900">ANALYTICS.1 Dashboard</div>
<div className="text-xs text-gray-500">Multi-tenant analytics control plane (MVP)</div>
</div>

<div className="flex items-center gap-2">
<select
className="rounded-xl border px-3 py-2 text-sm"
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
>
{tenants.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>

<button
className="rounded-xl border px-3 py-2 text-sm hover:bg-gray-50"
onClick={load}
disabled={loading}
>
{loading ? "Loading…" : "Refresh"}
</button>

<button
className="rounded-xl bg-gray-900 px-3 py-2 text-sm text-white hover:opacity-90"
onClick={simulateTraffic}
disabled={loading}
title="Generates sample events so you can see the charts update."
>
Simulate traffic
</button>
</div>
</div>
</header>

<main className="mx-auto max-w-6xl px-4 py-6">
{/* Date controls */}
<div className="mb-6 flex flex-wrap items-center gap-2">
<div className="text-sm text-gray-600">Date range:</div>
<button
className="rounded-xl border px-3 py-2 text-sm hover:bg-gray-50"
onClick={() => {
setFrom(daysAgoMs(7));
setTo(Date.now());
}}
>
Last 7 days
</button>
<button
className="rounded-xl border px-3 py-2 text-sm hover:bg-gray-50"
onClick={() => {
setFrom(daysAgoMs(30));
setTo(Date.now());
}}
>
Last 30 days
</button>
<button
className="rounded-xl border px-3 py-2 text-sm hover:bg-gray-50"
onClick={() => {
setFrom(daysAgoMs(90));
setTo(Date.now());
}}
>
Last 90 days
</button>
<button
className="rounded-xl bg-white border px-3 py-2 text-sm hover:bg-gray-50"
onClick={load}
>
Apply
</button>

{error ? <div className="ml-2 text-sm text-red-600">{error}</div> : null}
</div>

{/* KPIs */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card title="Pageviews" value={fmtInt(k.pageviews)} subtitle={tenantId} />
<Card title="Clicks" value={fmtInt(k.clicks)} subtitle="CTA + interactions" />
<Card title="Signups" value={fmtInt(k.signups)} subtitle="Form / onboarding" />
<Card title="Signup rate" value={fmtPct(k.conversionRate)} subtitle="Signups / pageviews" />
</div>

{/* Charts */}
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<div className="mb-3 text-sm font-semibold text-gray-900">Traffic over time</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={series}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="pageviews" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="signups" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="purchases" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>

<div className="rounded-2xl border bg-white p-4 shadow-sm">
<div className="mb-3 text-sm font-semibold text-gray-900">Daily signups</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={series}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="signups" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>

{/* Tables */}
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Table
title="Top pages"
columns={[
{ key: "url", label: "URL" },
{ key: "count", label: "Pageviews", render: (r) => fmtInt(r.count) },
]}
rows={data?.topPages || []}
rowKey={(r) => r.url}
/>

<Table
title="Top referrers"
columns={[
{ key: "referrer", label: "Referrer" },
{ key: "count", label: "Pageviews", render: (r) => fmtInt(r.count) },
]}
rows={data?.topReferrers || []}
rowKey={(r) => r.referrer}
/>
</div>

<div className="mt-6">
<Table
title="Campaigns (by events)"
columns={[
{ key: "campaign", label: "Campaign" },
{ key: "events", label: "Events", render: (r) => fmtInt(r.events) },
]}
rows={data?.campaigns || []}
rowKey={(r) => r.campaign}
/>
</div>

<footer className="mt-10 text-xs text-gray-500">
Tip: Once deployed, keep the dashboard on your Web2 twin for HTTPS trust, and treat ANALYTICS.1 as the namespace and API brand.
</footer>
</main>
</div>
);
}

Create the frontend project

Use Vite + React + Tailwind + Recharts:

 
# in a new terminal
npm create vite@latest analytics-dashboard -- --template react
cd analytics-dashboard
npm i recharts
npm i
npm run dev

Add Tailwind

 
npm i -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Update tailwind.config.js:

 
export default {
content: ["./index.html", "./src/**/*.{js,jsx}"],
theme: { extend: {} },
plugins: [],
};

Update src/index.css:

 
@tailwind base;
@tailwind components;
@tailwind utilities;

Replace src/App.jsx with the code above.


Best use for this dashboard (how to deploy it under ANALYTICS.1)

Recommended deployment pattern (dual-stack):

  • Host dashboard at your Web2 twin: https://analytics.website/dashboard (or similar)

  • Brand the product as ANALYTICS.1 Dashboard

  • Provide gateway access links like: https://hns.to/analytics.1 that redirect users to the Web2 dashboard

Who it serves best:

  • You (admin): oversee all WEB-3 Suite tenants

  • Partners: view their own tenant dashboards

  • Future buyers: immediately see the “namespace-as-a-network” story

What to build next to increase value quickly:

  1. Add tenant onboarding + API keys

  2. Add role-based access (admin/partner/viewer)

  3. Add conversion funnels and a simple alert system

  4. Add on-chain event ingestion (wallet connect, mints, lease payments)

Next.js + Postgres + ClickHouse (later) is the best balance of  speed to ship, enterprise credibility, and scaling headroom.

Frontend + API

  • Next.js (App Router) + TypeScript

  • Tailwind for UI

  • Recharts (or Tremor) for charts

  • Host on Vercel (fast deploys, global CDN)

Auth / Tenancy

  • Clerk or Auth.js (NextAuth)

    • Clerk is fastest if you want polished org/role management.

    • Auth.js is great if you want “own it” and keep costs down.

  • Use “Organizations” / “Teams” for multi-tenant access

Primary data store

  • Postgres (Supabase or Neon)

    • Supabase if you want admin UI, edge functions, and storage built-in.

    • Neon if you want pure Postgres + excellent scaling.

Event ingestion

  • Start with: Next.js API route (or a tiny Node service) writing to Postgres

  • After you get traction: move event storage to ClickHouse for cheap, fast analytics queries.

Why this stack wins

  • You can ship a serious MVP in days.

  • Postgres is perfect for tenants/users/billing/config.

  • ClickHouse is the industry standard for high-volume analytics events (PostHog uses it).

  • This stack is easy to sell to partners/investors.

What lives where

  • Postgres: users, tenants, sites, API keys, feature flags, billing, alerts

  • ClickHouse: raw events + aggregated rollups


Alternative 1 (fastest MVP): Next.js + Supabase only

To launch very quickly and keep infra simple:

  • Next.js + Supabase (Postgres + Auth + Storage + Edge Functions)

  • Store events in Postgres first, roll up daily aggregates

  • When the dataset grows, add ClickHouse later

Pros

  • One vendor does most things

  • Very fast to implement multi-tenancy

  • Great dashboards/admin convenience

Cons

  • Postgres is not ideal for massive event volumes long-term (but fine early)


Alternative 2 (privacy-first + low overhead): Cloudflare stack –  “web3-native vibes” + global edge + low cost:

  • Cloudflare Pages (frontend)

  • Cloudflare Workers (ingest API at the edge)

  • D1 (SQLite-ish) for config

  • R2 for raw event blobs

  • Optional: send events to ClickHouse later

Pros

  • Very cheap at scale for ingestion

  • Extremely fast globally

  • Great for “endpoint” positioning (analytics.1 feels like edge infra)

Cons

  • More engineering complexity than Next.js+Supabase

  • D1 isn’t as flexible as Postgres for complex analytics queries


Customization for the WEB-3 Network)

The Web-3 network will comprise many TLD sites, leasing, partner pages, payments, analytics). So it needs:

  • multi-tenant roles

  • partner dashboards

  • billing + metering

  • long-term scale

 

Appropriate stack

Next.js + TypeScript + Tailwind + Auth (Clerk/Auth.js) + Postgres (Supabase/Neon) + ClickHouse later

The shortest path:

  • Next.js + Supabase + Recharts

  • Add ClickHouse only when event volume demands it


Practical “starter architecture” 

Week 1 MVP

  • Next.js dashboard (tenant selector, KPIs, charts, referrers, campaigns)

  • Event snippet → /api/collect

  • Postgres tables:

    • tenants, sites, api_keys

    • events (or daily_rollups)

Week 2

  • Organizations/roles

  • Alerts (traffic spike/drop)

  • Funnel tracking

  • Export CSV

Week 3+

  • ClickHouse event store

  • Real-time dashboards

  • On-chain event connectors (wallet connect, mints, lease payments)