Adding a GoatCounter visitor counter to a Jekyll site without a browser-side request

Getting around CORS and blocker issues on GoatCounter endpoints by fetching the visitor count server-side, at Jekyll build time, instead of from the visitor's browser.

Adding a GoatCounter visitor counter to a Jekyll site without a browser-side request Adding a GoatCounter visitor counter to a Jekyll site without a browser-side request
Table of Contents

GoatCounter is a lightweight, privacy-friendly alternative to Google Analytics. Showing a simple “visitors / month” counter on the home page looks trivial — in practice, every browser-side approach hits a different wall. This guide documents the problem and the solution that actually works: fetch everything server-side, at build time.

What doesn’t work

The official stats API (/api/v0/stats/total) always requires a Bearer token, even with the dashboard set to public:

user@laptop:~$ curl -s https://MYCODE.goatcounter.com/api/v0/stats/total
{"error":"no Authorization header"}

The public /counter/*.json endpoint, meant to be embedded without a token, works with curl but breaks with fetch() from the browser:

Access to fetch at 'https://mycode.goatcounter.com/counter//.json?...'
from origin 'https://mydomain.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present

The <img> variant with /counter/*.svg gets around CORS in theory (images aren’t subject to it) — but still fails with a 499/net::ERR_FAILED, reproducible on both PC and mobile, unrelated to any local ad blocker.

The solution: fetch server-side, at build time

Instead of relying on the visitor’s browser, a Jekyll plugin fetches the number once, during jekyll build/jekyll serve, and injects it straight into the generated static HTML — no network request at all when the page loads.

# _plugins/goatcounter_stats.rb
require "net/http"
require "json"
require "date"

Jekyll::Hooks.register :site, :after_reset do |site|
  code = site.config.dig("analytics", "goatcounter_code")
  next if code.nil? || code.empty?

  begin
    end_date   = Date.today
    start_date = end_date - 30
    uri = URI("https://#{code}.goatcounter.com/counter//.json?start=#{start_date}&end=#{end_date}")

    Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 5) do |http|
      res = http.get(uri.request_uri)
      if res.is_a?(Net::HTTPSuccess)
        data = JSON.parse(res.body)
        site.config["goatcounter_visitors"] = (data["count_unique"] || data["count"] || "0").to_i
      end
    end
  rescue StandardError => e
    Jekyll.logger.warn "goatcounter_stats:", "fetch failed (#{e.message})"
  end
end
ca-certificates on Alpine

A ruby:3.3-alpine image doesn’t ship CA certificates by default — Net::HTTP over HTTPS fails silently (or crashes the Jekyll process at startup) until the ca-certificates package is installed in the Dockerfile.

Template side

The widget just reads a value that’s already been computed — no script, no network call on the visitor’s side:

<div class="stat-card">
  {% if site.goatcounter_visitors %}
    {% if site.goatcounter_visitors >= 1000 %}
      <div class="stat-value">{{ site.goatcounter_visitors | divided_by: 1000.0 | round: 1 }}k</div>
    {% else %}
      <div class="stat-value">{{ site.goatcounter_visitors }}</div>
    {% endif %}
  {% else %}
    <div class="stat-value">—</div>
  {% endif %}
  <div class="stat-label">Visitors / month</div>
</div>

Configuration

# _config.yml
analytics:
  goatcounter_code: "mycode"

Checking

docker compose -f docker-compose.public.yml up -d --build
docker logs infops-public | grep -i goatcounter   # no error = OK

curl -s http://localhost:4000/ | grep -A3 "Visitors / month"
# → the number must appear directly in the HTML, not "—"

Result

  • No browser-side network call: no CORS, no blocker, no 499 possible.
  • The number is already in the HTML by the time the page reaches the visitor.
  • Deliberate trade-off: the value only updates on the next build/reload, not in real time — more than enough for a monthly counter.

Comments