<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Abhishekh Singh</title>
    <description>The latest articles on Forem by Abhishekh Singh (@abhishekhsingh).</description>
    <link>https://forem.com/abhishekhsingh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3913415%2F5e45e5ed-7550-4d10-a00d-c1274435d5ef.jpeg</url>
      <title>Forem: Abhishekh Singh</title>
      <link>https://forem.com/abhishekhsingh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/abhishekhsingh"/>
    <language>en</language>
    <item>
      <title>How I Built a Modern Dashboard for GoatCounter - Rate Limiting, World Maps, and a Single HTML File</title>
      <dc:creator>Abhishekh Singh</dc:creator>
      <pubDate>Tue, 05 May 2026 11:24:58 +0000</pubDate>
      <link>https://forem.com/abhishekhsingh/how-i-built-a-modern-dashboard-for-goatcounter-rate-limiting-world-maps-and-a-single-html-file-ifo</link>
      <guid>https://forem.com/abhishekhsingh/how-i-built-a-modern-dashboard-for-goatcounter-rate-limiting-world-maps-and-a-single-html-file-ifo</guid>
      <description>&lt;p&gt;I use GoatCounter for my personal site analytics. If you haven't heard of it — it's a privacy-first, open-source analytics tool that doesn't track personal data. No cookies, no consent banners, lightweight script. I love it.&lt;/p&gt;

&lt;p&gt;But the built-in dashboard is... minimal. It shows you the data, and that's about it. No interactive charts. No world map. No visual drill-down into browser versions or regions. For a tool that collects genuinely useful data, the presentation felt like it was leaving a lot on the table.&lt;/p&gt;

&lt;p&gt;So I built my own dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo (no account needed):&lt;/strong&gt; &lt;a href="https://abhishekhsingh.github.io/goatcounter-dashboard" rel="noopener noreferrer"&gt;https://abhishekhsingh.github.io/goatcounter-dashboard&lt;/a&gt; — click "Try Demo" to see it with sample data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/abhishekhsingh/goatcounter-dashboard" rel="noopener noreferrer"&gt;https://github.com/abhishekhsingh/goatcounter-dashboard&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I wanted to build
&lt;/h2&gt;

&lt;p&gt;I had a few constraints in mind from the start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single HTML file.&lt;/strong&gt; No npm, no webpack, no build step. You should be able to fork the repo, enable GitHub Pages, and have a working dashboard. Or just download &lt;code&gt;index.html&lt;/code&gt; and open it locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No server.&lt;/strong&gt; The dashboard talks directly to your GoatCounter instance via the official API. My browser, your GoatCounter — nothing in between.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No tracking.&lt;/strong&gt; Ironic to add analytics to an analytics dashboard. Zero telemetry, zero phone-home.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy-first.&lt;/strong&gt; API key stored in your browser's localStorage only. Never sent anywhere except your own GoatCounter instance.
With those constraints locked, I started building.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;React 18 + Recharts, loaded from CDN via &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags. JSX compiled in-browser by Babel standalone. Plain CSS with custom properties for theming. No bundler, no transpiler pipeline — the browser does everything.&lt;/p&gt;

&lt;p&gt;Five CDN dependencies total: React, ReactDOM, Recharts, Babel standalone, and prop-types (a silent peer dependency Recharts needs at runtime).&lt;/p&gt;

&lt;p&gt;The entire dashboard is about 3,000 lines in a single &lt;code&gt;index.html&lt;/code&gt;. I know that sounds like a lot for one file, but it's surprisingly maintainable when you treat it as a self-contained application with clear component boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the dashboard looks like
&lt;/h2&gt;

&lt;p&gt;Here's what you get when you connect to your GoatCounter instance (or try the demo mode):&lt;/p&gt;

&lt;p&gt;The layout, top to bottom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 KPI cards&lt;/strong&gt; with period-over-period trend arrows (▲ +12.5% vs previous period)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic area chart&lt;/strong&gt; — visitors over time with gradient fill&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Top Pages&lt;/strong&gt; with click-to-expand referrer drill-down per page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 donut charts&lt;/strong&gt; — Browsers, OS, Devices — each with click-to-drill into versions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choropleth world map&lt;/strong&gt; with 174 countries, colored by visitor count, with a country list below that drills into regions/states&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Languages&lt;/strong&gt; card&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Campaigns&lt;/strong&gt; card (only shows when UTM data exists)
Dark mode is the default. There's a light theme toggle if you prefer that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The interesting technical challenges
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Rate limiting without a backend
&lt;/h3&gt;

&lt;p&gt;GoatCounter's API has a token-bucket rate limit — about 4 requests per second. My dashboard needs 13 API calls on initial load to populate all the cards. A naive Promise.all would fire all 13 at once and get throttled immediately.&lt;/p&gt;

&lt;p&gt;I built a strict-sequential request queue. Each request waits for the previous one to &lt;em&gt;complete&lt;/em&gt; (not just start), with a 500ms gap between completions. On the wire, that's about 2 requests per second including CORS preflights.&lt;/p&gt;

&lt;p&gt;But 13 sequential requests at 500ms each means a 6-7 second load time. That's too slow. So I added lazy loading — the dashboard doesn't fetch data for cards that aren't visible yet.&lt;/p&gt;

&lt;p&gt;Four tiers, triggered by IntersectionObserver:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tier 1 (immediate):&lt;/strong&gt; KPI totals + traffic chart — 3 requests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 2 (scroll to donut row):&lt;/strong&gt; Browsers, OS, Devices&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 3 (scroll to countries):&lt;/strong&gt; Locations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 4 (scroll to bottom):&lt;/strong&gt; Campaigns
The initial visible load is just 3-4 requests. Below-the-fold cards load as you scroll to them. Combined with a 60-second localStorage response cache, repeat visits are nearly instant.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. A world map from TopoJSON with no build step
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5hssxdu19gat39ymt2qi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5hssxdu19gat39ymt2qi.png" alt="Dashboard in dark mode showing worldmap" width="800" height="754"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wanted a choropleth world map — countries colored by visitor count. The standard approach is d3-geo at runtime, but d3 is way too heavy to load from CDN for just a map.&lt;/p&gt;

&lt;p&gt;Instead, I wrote a one-time Node.js script (&lt;code&gt;scripts/build-world-map.js&lt;/code&gt;) that converts Natural Earth 110m TopoJSON into pre-projected SVG paths. The script handles antimeridian wrapping (Russia, Fiji, Alaska all cross the date line), applies a Natural Earth projection, and outputs a plain JS file with 174 country objects — each containing the ISO code, country name, and a pre-computed SVG path &lt;code&gt;d&lt;/code&gt; attribute.&lt;/p&gt;

&lt;p&gt;The generated file (&lt;code&gt;assets/world-map.js&lt;/code&gt;) is loaded as a separate non-Babel script tag. This is deliberate — Babel standalone recompiles the entire &lt;code&gt;&amp;lt;script type="text/babel"&amp;gt;&lt;/code&gt; block on every page load, so keeping the 27KB of map paths outside that block saves compilation time.&lt;/p&gt;

&lt;p&gt;The map itself is plain SVG with CSS hover effects. Countries with visitor data get colored on a sqrt scale (so small-count countries aren't invisible next to large ones). Hover shows a tooltip. When you click a country in the list below the map, all its disjoint territories highlight together — mainland US, Alaska, and Hawaii all light up.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. GoatCounter's timezone trap
&lt;/h3&gt;

&lt;p&gt;This one was subtle. GoatCounter's hourly traffic data returns an array indexed by hour — index 0 is midnight, index 11 is 11 AM. But these hours are in your &lt;em&gt;site's configured timezone&lt;/em&gt;, not UTC.&lt;/p&gt;

&lt;p&gt;My chart was building ISO date strings with a "Z" suffix (UTC), then letting JavaScript's Date object parse them. Since my browser is in IST (UTC+5:30), every data point shifted by 5.5 hours. The traffic chart showed a spike at 2 AM that was actually a spike at 7:30 AM.&lt;/p&gt;

&lt;p&gt;The fix: parse the hour integer directly and format it for display without ever creating a Date object. The data is already in the right timezone — just don't let JavaScript "help" you with timezone conversion.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Drill-down everywhere
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F33dwy36xwyzd87x7lkul.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F33dwy36xwyzd87x7lkul.png" alt="Dashboard in dark mode showing all donut cards and drill down" width="800" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The built-in GoatCounter dashboard lets you click on a browser or country to see detailed breakdowns. I wanted the same thing.&lt;/p&gt;

&lt;p&gt;Now every card supports drill-down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Click a &lt;strong&gt;browser&lt;/strong&gt; → see version breakdown (Chrome 124, Chrome 123...)&lt;/li&gt;
&lt;li&gt;Click an &lt;strong&gt;OS&lt;/strong&gt; → see version breakdown (Windows 11, Windows 10...)&lt;/li&gt;
&lt;li&gt;Click a &lt;strong&gt;device category&lt;/strong&gt; → see pixel widths&lt;/li&gt;
&lt;li&gt;Click a &lt;strong&gt;country&lt;/strong&gt; → see regions/states (the map highlights the selected country too)&lt;/li&gt;
&lt;li&gt;Click a &lt;strong&gt;page&lt;/strong&gt; → see referrer sources&lt;/li&gt;
&lt;li&gt;Click a &lt;strong&gt;campaign&lt;/strong&gt; → see referrer URLs
The donut chart slices pop out when selected — a subtle +6px radius expansion with an accent stroke. It's a small detail, but it makes the interaction feel responsive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Demo mode that doesn't cheat
&lt;/h3&gt;

&lt;p&gt;I wanted people to try the dashboard without needing a GoatCounter account. So I built a demo mode with a &lt;code&gt;buildDemoData()&lt;/code&gt; generator function (~280 lines) that creates self-consistent fake data on demand.&lt;/p&gt;

&lt;p&gt;The generated data includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Weekday/weekend traffic patterns (weekdays are higher)&lt;/li&gt;
&lt;li&gt;Two viral spikes baked into the 30-day curve (a Hacker News hit on day 8, a Reddit hit on day 22)&lt;/li&gt;
&lt;li&gt;40 countries lighting up the world map&lt;/li&gt;
&lt;li&gt;15 realistic blog post paths about distributed systems and MCP&lt;/li&gt;
&lt;li&gt;Referrer breakdowns (Google, HN, Reddit, Twitter, LinkedIn)&lt;/li&gt;
&lt;li&gt;Campaign tracking (hackernews-apr, reddit-selfhosted, twitter-thread)
Everything sums correctly — country visitor counts roughly equal total visitors, browser percentages add to 100%. The generator produces different scaled data per date range (7d, 30d, 90d) so switching ranges actually changes the view.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All drill-downs work in demo mode too — click Chrome and you see version data, click United States and you see states. No API calls, all from static lookup tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting
&lt;/h2&gt;

&lt;p&gt;Fork the repo → enable GitHub Pages → done. Literally that simple.&lt;/p&gt;

&lt;p&gt;Or if you want to host it yourself: download &lt;code&gt;index.html&lt;/code&gt;, open it in a browser. It works from the file system — no server needed.&lt;/p&gt;

&lt;p&gt;The dashboard connects to &lt;em&gt;your&lt;/em&gt; GoatCounter instance using &lt;em&gt;your&lt;/em&gt; API key. I never see your data, because there's no intermediary. Your browser talks directly to your GoatCounter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Building a non-trivial single-page application inside one HTML file with no build step is surprisingly viable in 2026. React + Recharts via CDN, JSX in the browser, CSS custom properties for theming — it all just works. The trade-off is that Babel standalone prints a console warning every time (yes, I know it's not recommended for production), but the actual performance is fine for a dashboard that loads once and stays open.&lt;/p&gt;

&lt;p&gt;The rate-limiting work was the most educational part. GoatCounter doesn't document its rate limit algorithm in detail, so I had to discover it empirically — firing bursts of requests and observing where it started returning 429s. The token-bucket model meant that even 350ms gaps between requests weren't enough during a 13-request burst. Only a strict sequential queue with completion-based timing kept it reliably under the limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://abhishekhsingh.github.io/goatcounter-dashboard" rel="noopener noreferrer"&gt;https://abhishekhsingh.github.io/goatcounter-dashboard&lt;/a&gt; — click "Try Demo"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/abhishekhsingh/goatcounter-dashboard" rel="noopener noreferrer"&gt;https://github.com/abhishekhsingh/goatcounter-dashboard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MIT licensed. If you use GoatCounter and want richer visualizations, give it a shot. If you have feature ideas or find bugs, open an issue — I'm actively maintaining this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My Portfolio:&lt;/strong&gt; [&lt;a href="https://abhishekhsingh.github.io/" rel="noopener noreferrer"&gt;https://abhishekhsingh.github.io/&lt;/a&gt;]&lt;/p&gt;

</description>
      <category>goatcounter</category>
      <category>analytics</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
