How to Build a Custom Analytics Dashboard with HTML and JavaScript

Build a custom analytics dashboard with plain HTML, JavaScript and Chart.js: KPI cards, a trend chart and a date filter that fetches JSON from your API.

You have numbers in a database and a pile of SQL queries, but nobody on your team will open a terminal to read them. A custom analytics dashboard fixes that. It is a single page that asks your API for JSON and draws four numbers and one chart.

This article builds that page with plain HTML, vanilla JavaScript and one charting library, Chart.js. There is no framework and no build step. You can open the file from a static folder, a CDN bucket or the same server that hosts your site.

You will define a small JSON contract, mock the API so you can build before the backend exists, and then write the page: KPI cards, a trend chart, a date filter, and loading and error states. The metrics come from the queries in SQL queries for user analytics. The server that produces the JSON is the subject of building your own analytics API in Node.js, and I will not rebuild it here.

Executive Summary: A useful static dashboard is a page, a JSON contract and about 100 lines of JavaScript. The contract keeps the page dumb: the server computes every number, and the browser only formats and draws. Chart.js handles the chart, fetch with an AbortController handles requests, and textContent keeps untrusted strings out of the DOM. Start here, ship it to your team, and move to React only when interaction needs outgrow a single file.

What a Custom Analytics Dashboard Must Do

Requirements first, because dashboards grow wild without them. This one answers one question: “Is traffic healthy, and is it converting?” It shows four KPI cards, one line chart over time and a date filter. Nothing else.

  • Pageviews: the count of page_view events in the range.
  • Visitors: distinct anonymous_id values in the range. This counts browsers, not people, which is the same simplification the API article makes.
  • Sessions: distinct session_id values in the range.
  • Conversion rate: visitors who reached purchase_completed divided by visitors who reached page_view, from the funnel endpoint.

Write these definitions on the page itself or in a tooltip. The number “visitors” means different things in different products, and a dashboard without definitions starts arguments. For the SQL behind each definition, see the earlier query article.

The JSON contract

The dashboard calls two endpoints from the series API: GET /v1/stats/pageviews and GET /v1/stats/funnel. Both accept from and to as inclusive UTC dates in YYYY-MM-DD form. Fix this contract before you write any UI code, because it is the seam between two teams, even if both are you.

GET /v1/stats/pageviews?from=2025-09-05&to=2025-10-04

{
  "from": "2025-09-05",
  "to": "2025-10-04",
  "totals": { "pageviews": 14210, "visitors": 5230, "sessions": 6890 },
  "daily": [
    { "date": "2025-09-05", "pageviews": 412, "visitors": 190 }
  ]
}

GET /v1/stats/funnel?from=2025-09-05&to=2025-10-04

{
  "from": "2025-09-05",
  "to": "2025-10-04",
  "steps": [
    { "event_name": "page_view", "visitors": 5230 },
    { "event_name": "add_to_cart", "visitors": 610 },
    { "event_name": "purchase_completed", "visitors": 140 }
  ]
}

The numbers above are illustrative. Two design choices deserve a note. First, totals.visitors is a separate distinct count over the whole range, and it is not the sum of daily visitors. A person who visits on five days counts five times in the daily sum and once in the total. If the API only returned daily rows and the browser summed them, the headline number would be wrong. Second, every date is a UTC day, which avoids the time zone drift described in the query article.

Mock the API so you can build first

Waiting for a backend blocks the front end for no reason. A short Node script returns deterministic fake data in the same shape, with the CORS headers a browser needs. Save it as mock-api.mjs. It uses only built-in modules, so there is nothing to install.

import http from 'node:http';

const DAY = 86_400_000;
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;

function buildDays(from, to) {
  const days = [];
  for (let t = Date.parse(from), i = 0; t <= Date.parse(to) && i < 366; t += DAY, i++) {
    const pageviews = Math.round(300 + 120 * Math.sin(i * 0.9) + 10 * (i % 7));
    days.push({
      date: new Date(t).toISOString().slice(0, 10),
      pageviews,
      visitors: Math.round(pageviews * 0.45),
    });
  }
  return days;
}

const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080');
  res.setHeader('Access-Control-Allow-Headers', 'Authorization');
  res.setHeader('Vary', 'Origin');
  if (req.method === 'OPTIONS') { res.writeHead(204).end(); return; }

  const from = url.searchParams.get('from') ?? '';
  const to = url.searchParams.get('to') ?? '';
  if (!ISO_DAY.test(from) || !ISO_DAY.test(to) || from > to) {
    res.writeHead(400, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'from and to must be YYYY-MM-DD, from <= to' }));
    return;
  }

  const daily = buildDays(from, to);
  const pageviews = daily.reduce((n, d) => n + d.pageviews, 0);
  const visitors = Math.round(daily.reduce((n, d) => n + d.visitors, 0) * 0.7);
  let body;
  if (url.pathname === '/v1/stats/pageviews') {
    body = { from, to, totals: { pageviews, visitors, sessions: Math.round(visitors * 1.3) }, daily };
  } else if (url.pathname === '/v1/stats/funnel') {
    body = { from, to, steps: [
      { event_name: 'page_view', visitors },
      { event_name: 'add_to_cart', visitors: Math.round(visitors * 0.12) },
      { event_name: 'purchase_completed', visitors: Math.round(visitors * 0.03) },
    ] };
  } else {
    res.writeHead(404).end();
    return;
  }
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(body));
});

server.listen(4000, () => console.log('mock analytics API on http://localhost:4000'));

Run it with node mock-api.mjs. Everything it returns is fake, so never screenshot it as if it were real traffic. The value is the shape: when the real API ships, you change one constant in the page and nothing else.

The page: HTML and CSS without a framework

The markup is small on purpose. A form holds the date filter, four article elements hold the KPI cards, and a canvas inside a figure holds the chart. Data attributes mark the elements that JavaScript updates, which keeps the CSS free of class names.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Acme Shop Analytics</title>
  <style>
    :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
    body { margin: 0 auto; padding: 1rem; max-width: 960px; }
    form { display: flex; gap: .75rem; flex-wrap: wrap; align-items: end; }
    label { display: grid; gap: .25rem; font-size: .85rem; }
    [data-role="cards"] { display: grid; gap: 1rem; margin-block: 1rem;
      grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
    article { border: 1px solid #8886; border-radius: 8px; padding: 1rem; }
    article h2 { margin: 0; font-size: .85rem; font-weight: 600; opacity: .7; }
    article p { margin: .25rem 0 0; font-size: 1.8rem; font-variant-numeric: tabular-nums; }
    figure { position: relative; height: 360px; margin: 0; }
    [data-role="status"] { min-height: 1.5rem; }
  </style>
</head>
<body>
  <h1>Acme Shop analytics</h1>
  <form>
    <label>From <input type="date" name="from" required></label>
    <label>To <input type="date" name="to" required></label>
    <label>API token <input type="password" name="token" autocomplete="off"></label>
    <button type="submit">Update</button>
    <button type="button" data-days="7">Last 7 days</button>
    <button type="button" data-days="30">Last 30 days</button>
  </form>
  <p data-role="status" role="status"></p>
  <section data-role="cards">
    <article><h2>Pageviews</h2><p data-kpi="pageviews">-</p></article>
    <article><h2>Visitors</h2><p data-kpi="visitors">-</p></article>
    <article><h2>Sessions</h2><p data-kpi="sessions">-</p></article>
    <article><h2>Conversion rate</h2><p data-kpi="conversion">-</p></article>
  </section>
  <figure><canvas aria-label="Daily pageviews and visitors"></canvas></figure>

  <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
  <script type="module" src="dashboard.js"></script>
</body>
</html>

Save this as index.html beside dashboard.js. The role="status" attribute makes screen readers announce loading and error messages. The fixed height on the figure matters: Chart.js sizes the canvas from its container, and a container with no height collapses the chart.

The data layer: fetch, abort and fail loudly

Dashboard bugs rarely sit in the drawing code. They sit in the requests: slow responses that arrive out of order, HTTP errors that fetch does not treat as failures, and empty results that crash the renderer. Look at the common wrong version first.

// WRONG: no status check, no cancellation
const data = await (await fetch(url)).json();
render(data);

This code has two faults. A 500 response with a JSON body renders garbage because fetch only rejects on network failure, which the MDN guide to using fetch explains. And if the user clicks “Last 7 days” and then “Last 30 days” quickly, a slow first response can land after the second and overwrite it. Here is the corrected data layer.

const API_BASE = 'http://localhost:4000';

async function getJson(path, params, signal) {
  const url = new URL(path, API_BASE);
  url.search = new URLSearchParams(params);
  const token = sessionStorage.getItem('stats_token');
  const res = await fetch(url, {
    signal,
    headers: token ? { Authorization: `Bearer ${token}` } : {},
  });
  if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}`);
  return res.json();
}

Now the delta is clear. The status check turns server errors into exceptions the UI can show. The signal parameter lets the caller cancel a stale request. The base URL is a constant, never a query-string parameter. If the page read ?api= from the URL, an attacker could send your team a link that makes the dashboard post its bearer token to their server.

Render KPI cards and the chart

The rest of dashboard.js wires the form, loads both endpoints in parallel and draws. Write numbers with textContent, never innerHTML. Today every value is a number, but the day someone adds a “top pages” table, page paths become untrusted strings that can carry markup.

const $ = (selector) => document.querySelector(selector);
const form = $('form');
const statusEl = $('[data-role="status"]');
const fmt = new Intl.NumberFormat('en-US');
let chart;
let controller;

const isoDay = (date) => date.toISOString().slice(0, 10);

function setRange(days) {
  const to = new Date();
  const from = new Date(to.getTime() - (days - 1) * 86_400_000);
  form.elements.from.value = isoDay(from);
  form.elements.to.value = isoDay(to);
}

function setKpi(name, text) {
  $(`[data-kpi="${name}"]`).textContent = text;
}

function drawChart(daily) {
  const data = {
    labels: daily.map((d) => d.date),
    datasets: [
      { label: 'Pageviews', data: daily.map((d) => d.pageviews), tension: 0.2 },
      { label: 'Visitors', data: daily.map((d) => d.visitors), tension: 0.2 },
    ],
  };
  if (chart) {
    chart.data = data;
    chart.update();
    return;
  }
  chart = new Chart($('canvas'), {
    type: 'line',
    data,
    options: {
      responsive: true,
      maintainAspectRatio: false,
      scales: { y: { beginAtZero: true } },
    },
  });
}

function render(pv, funnel) {
  setKpi('pageviews', fmt.format(pv.totals.pageviews));
  setKpi('visitors', fmt.format(pv.totals.visitors));
  setKpi('sessions', fmt.format(pv.totals.sessions));
  const first = funnel.steps[0]?.visitors ?? 0;
  const last = funnel.steps.at(-1)?.visitors ?? 0;
  setKpi('conversion', first ? `${((100 * last) / first).toFixed(1)}%` : 'n/a');
  drawChart(pv.daily);
}

async function load() {
  const { from, to, token } = form.elements;
  if (from.value > to.value) {
    statusEl.textContent = 'The start date must be on or before the end date.';
    return;
  }
  sessionStorage.setItem('stats_token', token.value);
  controller?.abort();
  controller = new AbortController();
  const params = { from: from.value, to: to.value };
  statusEl.textContent = 'Loading...';
  try {
    const [pv, funnel] = await Promise.all([
      getJson('/v1/stats/pageviews', params, controller.signal),
      getJson('/v1/stats/funnel', params, controller.signal),
    ]);
    render(pv, funnel);
    statusEl.textContent = `Showing ${pv.from} to ${pv.to} (UTC days)`;
  } catch (err) {
    if (err.name === 'AbortError') return;
    statusEl.textContent = `Could not load data: ${err.message}`;
  }
}

form.addEventListener('submit', (event) => { event.preventDefault(); load(); });
form.addEventListener('click', (event) => {
  const days = event.target.closest('[data-days]')?.dataset.days;
  if (days) { setRange(Number(days)); load(); }
});

setRange(30);
load();

Append the getJson function from the previous section to the top of this file, and the dashboard is complete. To run it, start node mock-api.mjs, serve the folder with python -m http.server 8080 (python3 on macOS and Linux), and open http://localhost:8080. The mock allows only that origin, which is deliberate.

Chart.js draws a line chart from the config above, and its line chart documentation lists every dataset option. Calling chart.update() after replacing the data is cheaper than destroying and rebuilding the chart on each filter change. In production, pin an exact Chart.js version in the script URL and add a Subresource Integrity hash, so a compromised CDN file cannot run in your reporting page.

Date filters that do not lie

The filter looks trivial and hides three bugs. The first is time zones. <input type="date"> yields a local calendar date, while the API reads UTC days. If you build “last 7 days” from new Date() in local time, a user in Tokyo sees a different “today” than the server. The code above uses UTC days via toISOString() and says so in the status line.

The second is the inclusive end date. A user who picks Oct 1 to Oct 3 expects three days of data. The API contract says to is inclusive, and the server converts it to a half-open range internally. Keep that conversion on the server so every client behaves the same.

The third is partial days. Today’s bar is incomplete, so a drop on the right edge of the chart is usually an artifact rather than an outage. Label today as “partial” in the tooltip, or exclude it from the default range. I have seen a team page an engineer at 8 a.m. over a chart that was simply drawn before most of the day’s traffic existed.

Loading, empty and error states

A dashboard that shows nothing on failure trains people to distrust it. Handle four states. While loading, keep the previous numbers visible and show “Loading…” in the status line. On success, show the date range. On an HTTP error, show the status code and keep the last good data. On an empty result, show zeros and say so.

The code above covers the first three. For the empty state, the API returns zero-filled days, so the chart draws a flat line at zero rather than an empty canvas. That is the right behavior: a flat line says “we looked and found nothing”, while a blank canvas says “something broke”. Ask the API to fill gaps instead of filling them in the browser, so every client agrees.

Why Chart.js, and what else you could use

The series stack rules pick one small charting option for this article, and Chart.js is it. It is widely used, draws on canvas, and has sensible defaults for responsive line charts. The table lists the honest trade-offs.

Option Size and effort Strength Weakness
Chart.js One script tag, small config Good defaults, many chart types, active docs Canvas output is harder to style per element and to make accessible
Hand-written SVG No dependency, more code Full control, crisp at any size, accessible text You build axes, scales and tooltips yourself
Apache ECharts Larger bundle Rich chart catalog for big dashboards More concepts than one line chart needs
Recharts Needs React Composable components Out of scope here, see the React article

Canvas has one real cost: screen readers cannot read the chart. The aria-label on the canvas helps a little. If accessibility is a hard requirement, add a visually hidden data table or switch to SVG. For an internal team dashboard, Chart.js is the fastest honest choice.

Security for a static dashboard

Static does not mean safe. The page holds a read credential and talks to a service full of business data. Apply four rules.

  • Restrict CORS. The API should allow only the dashboard’s exact origin, never *, as the MDN CORS guide describes.
  • Keep the token out of the source. The sample reads it from a password field and stores it in sessionStorage, which clears when the tab closes. Never commit a key into dashboard.js.
  • Prefer same-origin hosting behind a login. If your reverse proxy already authenticates staff, the page needs no token at all.
  • Render with textContent. Any string that came from an event, such as a page path or referrer, is attacker-controlled.

The trade-off is convenience. A password field is awkward, and sessionStorage is readable by any script on the page, so one injected script steals the token. That is why the third rule is the best one when you can use it.

How Real Systems Do This

Mature analytics tools separate the number crunching from the display. Matomo exposes a Reporting API that returns report data over HTTP, and Plausible documents a Stats API for the same purpose. Both let you build your own views on top of their aggregated numbers instead of scanning raw events in the browser.

That is the pattern you just built: the server aggregates, the browser draws. It is also why buying can beat building here. If your team needs sharing, alerts, saved views and permissions, a tool like Grafana or a hosted product gives you those on day one. A custom page wins when you need exactly four numbers, full control and zero licence cost.

Decision Framework

  1. How many people will open the dashboard, and do they need logins? If more than a handful, plan authentication before design.
  2. Does one page with a date filter answer their questions? If yes, stay static.
  3. Do you need drill-downs, saved filters or shared state? If yes, move to the React version.
  4. Where will the page be hosted, and which origin will the API allow?
  5. Who owns the metric definitions, and where are they written down?
  6. Can the API return zero-filled days and totals, so the browser stays thin?

When NOT to Use This

  • Many charts with cross-filtering. Once clicking a bar must update five other panels, hand-rolled state management turns into a framework. Use the React dashboard approach instead.
  • Non-technical consumers who need ad hoc questions. They will ask for filters you did not build. A BI tool you buy answers those without a developer.
  • Dashboards exposed to the public internet without an auth layer. A static page is easy to host and easy to leak. Put it behind a login or do not publish it.

Common Mistakes

  • Summing daily visitors in the browser for the headline, which overstates unique visitors for anyone who returns.
  • Skipping res.ok, which draws an error payload as if it were data.
  • Letting old requests finish after new ones, which shows the wrong date range under the right label.
  • Reading the API base URL from the query string, which lets a crafted link redirect your token.
  • Building date ranges in local time against a UTC API, which makes “today” disagree between people.
  • Using innerHTML for values from events, which turns tracked page paths into a cross-site scripting hole.

Key Takeaways

  • Define a JSON contract first, and mock it so the page can be built and tested alone.
  • Let the server compute totals, distinct counts and zero-filled days, and keep the browser thin.
  • Check res.ok and cancel stale requests with AbortController.
  • Use UTC days end to end, and say so in the interface.
  • Write values with textContent, keep tokens out of source, and allow-list the CORS origin.
  • Show loading, error and empty states, and label partial days.
  • Move to React only when you need shared state, not because a page feels old-fashioned.

FAQ

How do I build a custom analytics dashboard with HTML and JavaScript?

Define a JSON contract, then write one HTML page that fetches it. Use fetch to load totals and daily rows, write the totals into KPI cards, and draw the daily rows with a library such as Chart.js. Add a date form that triggers the same load function.

Which JavaScript chart library is best for a simple analytics dashboard?

Chart.js is a good default for a few line and bar charts, because one script tag and a short config produce a responsive chart. Choose SVG when accessibility or styling control matters most, and ECharts when you need a large chart catalog.

Can a static dashboard fetch data from my analytics API?

Yes, if the API sends the right CORS headers for the dashboard’s origin. Allow only that origin, handle the preflight request when you send an Authorization header, and avoid wildcard origins on any endpoint that returns business data.

How do I add a date range filter to a JavaScript dashboard?

Use two date inputs in a form, read their values on submit, and pass them as from and to query parameters. Agree on UTC days and an inclusive end date with the API, and cancel any in-flight request when the user changes the range.

Conclusion

A custom analytics dashboard does not need a framework. It needs a clear contract, honest definitions and careful handling of requests, dates and untrusted strings. When the page outgrows one file, you will already know which parts to carry into the React version.

Rule of thumb: the server computes, the browser draws, and the contract is the only thing they share.

Last updated on 9 October 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *