How to Build an Internal Metrics Dashboard From Your Analytics API
Build a live internal dashboard with the JustAnalytics API — real code included.
Last month I watched our ops team spend forty minutes trying to find our uptime stats. They had the JustAnalytics dashboard open. They had Slack open. They had our internal wiki open. The number they needed was sitting in three different tabs, and somehow none of them could agree on whether we'd hit our 99.9% target for June.
Embarrassing? Yeah, a little.
So I built an internal board. One page. Pageviews, error count, P95 latency, uptime percentage — the four numbers our standup actually cares about. Took an afternoon. Now it lives on the TV in the engineering corner. Nobody opens three tabs anymore.
This is the build guide. We're pulling data from the JustAnalytics REST API, caching it so we don't hammer endpoints every second, and rendering something you can throw on a TV or embed in your admin panel. The same pattern works for Plausible, Fathom, Sentry — whatever you're running. I'm using JustAnalytics here because that's what we use, and honestly because the API is less annoying than some alternatives I've dealt with.
What you'll have by the end
A single-page dashboard that:
- Pulls today's pageviews, error count, P95 latency, and uptime from the JustAnalytics API
- Caches responses for 30 seconds to avoid rate limits
- Authenticates without exposing your API key to the browser
- Auto-refreshes every minute
- Looks passable on a wall monitor (we're not winning design awards — I have zero CSS taste — but it's readable from ten feet away)
Prerequisites
- A JustAnalytics account with at least one site configured (the Free tier at 100K events/month works fine for this)
- Node.js 18+ installed
- An API key from your JustAnalytics dashboard (Settings → API Keys → Create Key with read scope)
- Basic familiarity with Express or Next.js API routes
If you don't have an API key yet, grab one from the dashboard. Read-only scope is all you need — we're pulling metrics, not pushing events.
Step 1: Set up the backend proxy
The API key should never touch the browser. Even for an internal dashboard, someone will eventually screenshot it or paste the URL in Slack. We'll proxy everything through a tiny Express server.
Create your project and install dependencies:
mkdir internal-dash && cd internal-dash
npm init -y
npm install express node-fetch
Create server.js:
// server.js
import express from 'express';
import fetch from 'node-fetch';
const app = express();
const PORT = process.env.PORT || 3001;
const API_KEY = process.env.JA_API_KEY;
const SITE_ID = process.env.JA_SITE_ID;
if (!API_KEY || !SITE_ID) {
console.error('Missing JA_API_KEY or JA_SITE_ID environment variables');
process.exit(1);
}
const cache = {};
const CACHE_TTL = 30000; // 30 seconds
async function fetchWithCache(endpoint) {
const now = Date.now();
if (cache[endpoint] && now - cache[endpoint].timestamp < CACHE_TTL) {
return cache[endpoint].data;
}
const res = await fetch(`https://api.justanalytics.app/v1/${endpoint}`, {
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`API returned ${res.status}`);
}
const data = await res.json();
cache[endpoint] = { data, timestamp: now };
return data;
}
app.get('/api/metrics', async (req, res) => {
try {
const [pageviews, errors, apm, uptime] = await Promise.all([
fetchWithCache(`sites/${SITE_ID}/pageviews?period=today`),
fetchWithCache(`sites/${SITE_ID}/errors?period=today`),
fetchWithCache(`sites/${SITE_ID}/apm/summary?period=today`),
fetchWithCache(`sites/${SITE_ID}/uptime?period=30d`),
]);
res.json({
pageviews: pageviews.total,
errors: errors.total,
p95_latency_ms: apm.p95_latency_ms,
uptime_percent: uptime.uptime_percent,
cached_at: new Date().toISOString(),
});
} catch (err) {
console.error('Metrics fetch failed:', err.message);
res.status(500).json({ error: 'Failed to fetch metrics' });
}
});
app.use(express.static('public'));
app.listen(PORT, () => {
console.log(`Dashboard running at http://localhost:${PORT}`);
});
Add "type": "module" to your package.json so ES imports work, and create your env file:
# .env (don't commit this)
JA_API_KEY=your_api_key_here
JA_SITE_ID=your_site_id_here
The cache logic is dead simple — an in-memory object with a 30-second TTL. For a single-page dashboard with four widgets, this is plenty. If you're scaling to multiple dashboards or need persistence across restarts, swap in Redis (I'll show that later).
Don't overcomplicate it on day one. I've seen teams spend a week building "proper" caching infrastructure for dashboards that three people look at. If you're building developer tools for your team, the DevOS workflow platform has some interesting patterns for this kind of internal tooling.
Step 2: Build the frontend
Create a public folder and add index.html:
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Internal Metrics</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #f1f5f9;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2rem;
max-width: 800px;
padding: 2rem;
}
.card {
background: #1e293b;
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.card h2 { font-size: 3rem; margin-bottom: 0.5rem; }
.card p { color: #94a3b8; font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; }
.good { color: #4ade80; }
.warn { color: #facc15; }
.bad { color: #f87171; }
.meta { position: fixed; bottom: 1rem; right: 1rem; color: #64748b; font-size: 0.75rem; }
</style>
</head>
<body>
<div class="grid">
<div class="card">
<h2 id="pageviews">--</h2>
<p>Pageviews today</p>
</div>
<div class="card">
<h2 id="errors">--</h2>
<p>Errors today</p>
</div>
<div class="card">
<h2 id="latency">--</h2>
<p>P95 latency (ms)</p>
</div>
<div class="card">
<h2 id="uptime">--</h2>
<p>30-day uptime</p>
</div>
</div>
<div class="meta">Last updated: <span id="updated">--</span></div>
<script>
async function fetchMetrics() {
try {
const res = await fetch('/api/metrics');
const data = await res.json();
document.getElementById('pageviews').textContent = data.pageviews.toLocaleString();
document.getElementById('errors').textContent = data.errors.toLocaleString();
document.getElementById('errors').className = data.errors > 50 ? 'bad' : data.errors > 10 ? 'warn' : 'good';
document.getElementById('latency').textContent = data.p95_latency_ms;
document.getElementById('latency').className = data.p95_latency_ms > 500 ? 'bad' : data.p95_latency_ms > 200 ? 'warn' : 'good';
document.getElementById('uptime').textContent = data.uptime_percent.toFixed(2) + '%';
document.getElementById('uptime').className = data.uptime_percent < 99.9 ? 'bad' : data.uptime_percent < 99.95 ? 'warn' : 'good';
document.getElementById('updated').textContent = new Date(data.cached_at).toLocaleTimeString();
} catch (err) {
console.error('Failed to fetch metrics:', err);
}
}
fetchMetrics();
setInterval(fetchMetrics, 60000); // refresh every minute
</script>
</body>
</html>
Start the server:
node --env-file=.env server.js
Open http://localhost:3001 and you should see four cards with live numbers. The color coding? Totally arbitrary thresholds I made up — adjust them to match your SLOs. Our team considers anything under 99.9% uptime a conversation starter, so that's where the yellow kicks in.
Is it pretty? No. Does it work? Yes. Ship it.
Step 3: Add Redis caching for production
The in-memory cache works, but if you restart the server or run multiple instances (behind a load balancer, in Kubernetes, whatever), each instance builds its own cache and you multiply your API calls. Redis solves this.
npm install ioredis
Update the cache logic in server.js:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
const CACHE_TTL = 30; // seconds
async function fetchWithCache(endpoint) {
const cacheKey = `ja:${endpoint}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const res = await fetch(`https://api.justanalytics.app/v1/${endpoint}`, {
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`API returned ${res.status}`);
}
const data = await res.json();
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(data));
return data;
}
Now multiple dashboard instances share the same cache. The API sees one request every 30 seconds regardless of how many browsers are polling your backend.
Step 4: Lock it down with basic auth
If this dashboard is internal-only, you probably want some authentication. The quickest option is HTTP Basic Auth:
npm install express-basic-auth
Add it to server.js:
import basicAuth from 'express-basic-auth';
app.use(basicAuth({
users: { 'ops': process.env.DASH_PASSWORD },
challenge: true,
realm: 'Internal Dashboard',
}));
Set DASH_PASSWORD in your environment. Now anyone hitting the dashboard gets a browser login prompt. Not sophisticated, but it keeps random internet traffic out.
For teams using SSO, you'd swap this out for an OAuth flow or a Cloudflare Access tunnel — but that's a longer detour. Basic auth ships in five minutes. And look, I know security folks will cringe at basic auth, but for an internal dashboard behind a VPN that shows four numbers? It's fine. Pick your battles. If you're tracking phone calls alongside web metrics, VeloCalls integrates with similar dashboard patterns.
Common errors and how to fix them
Error: API returned 401
Your API key is wrong or expired. Regenerate one from Settings → API Keys in the JustAnalytics dashboard. Make sure you're copying the full key (they're long).
Error: API returned 429
You've hit the rate limit. On the Free tier that's 100 requests per minute. Check that your cache is actually working — add a console.log inside fetchWithCache to confirm you're not bypassing it. If you're polling from multiple browsers without a shared cache, each one burns through your limit separately.
TypeError: Cannot read property 'total' of undefined
The API response structure might differ based on your plan or configuration. Add logging to see what you're actually getting back:
console.log('Pageviews response:', JSON.stringify(pageviews, null, 2));
Then adjust your property access accordingly. The endpoints are documented, but I've hit minor schema changes between API versions before. Annoying, but the logs tell you exactly what's wrong.
ECONNREFUSED 127.0.0.1:6379
You switched to Redis caching but Redis isn't running locally. Either start Redis (brew services start redis on Mac, sudo systemctl start redis on Linux) or point REDIS_URL at a cloud instance. If you don't need shared caching yet, stick with the in-memory version.
What's next (if you're feeling ambitious)
The basics are done. You've got a working dashboard. But there's more you can pull if you want it. The JustAnalytics API exposes session replay counts, error breakdowns by type, Web Vitals percentiles, and uptime check history — I just didn't need those for our TV board.
Teams running paid campaigns often overlay this with click fraud data from ClickzProtect to detect invalid traffic before it wastes your budget. If your ROAS looks off, you want to know whether it's a product problem or a traffic quality problem. (Spoiler: it's usually traffic quality.)
For more advanced setups:
- Adding historical charts with Chart.js or Recharts — the API supports
period=7d,period=30d, etc. - Embedding in your admin panel instead of a standalone page — just move the fetch logic into your existing backend
- Forwarding to Grafana if you're already running it — the API responses translate directly to Grafana JSON datasource format
- Webhook alerts for thresholds — though honestly, the built-in alerting in JustAnalytics Pro ($49/month) might save you the custom work
We've covered how to track SPA route changes if you're seeing weird pageview numbers, and the observability glossary explains the terminology if some of these metrics are new to you.
One thing I wish I'd done earlier: export the dashboard URL to a shared bookmark. Sounds obvious. Took us three weeks to do it. Before that, people kept asking "where's the dashboard again?" For teams managing email campaigns, JustEmails provides similar API-driven metrics you can pull into unified dashboards.
Frequently Asked Questions
What rate limits does the JustAnalytics API have?
The API allows 100 requests per minute on the Free tier and 1,000 requests per minute on Pro. Each endpoint returns X-RateLimit-Remaining and X-RateLimit-Reset headers so you can implement backoff. For internal dashboards polling every 30-60 seconds, you'll stay well under these limits even with multiple widgets.
Can I pull data for multiple sites into one dashboard?
Yes. Each API request includes a site_id parameter. Loop through your site IDs and aggregate the responses client-side or in your backend. If you're on Pro with up to 5 sites, a single internal board can show all of them in parallel panels.
How do I handle API authentication securely in a team environment?
Never expose your API key to the browser. Proxy all requests through your own backend (Express, Next.js API route, Rails controller, etc.) and store the key in environment variables. For team dashboards, use a service account key with read-only scopes.
Should I use the REST API or webhooks for real-time data?
For dashboards that refresh every 30-60 seconds, polling the REST API is simpler and sufficient. Webhooks make more sense for event-driven workflows like Slack alerts on errors or PagerDuty integration — not for periodic dashboard refreshes.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier at 100K events/month, Pro at $49/month ($39/month if you pay annually).
Start free → · AI Command Center ($25/month add-on)
Author at JustAnalytics.