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:
-
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
-
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
-
A “generic endpoint” model
What this offers:
-
tenant.analytics.1as a namespace concept (even if it’s routed today) -
api.analytics.1for 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.1that 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:
-
Add tenant onboarding + API keys
-
Add role-based access (admin/partner/viewer)
-
Add conversion funnels and a simple alert system
-
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)
