Skip to main content

GoatCounter analytics

· 6 min read ·
Docux
Curious explorer, a bit of a mad experimenter, and a bit of a contributor.

Developer Development AI 50% License: MIT

Analyzing website traffic while respecting user privacy is an increasing challenge. Goatcounter is an open‑source, cookieless analytics solution that addresses this need. Here's how I integrated Goatcounter with my Docusaurus site, exploring three different approaches including proper tracking for single‑page application (SPA) navigation.

GoatCounter

Goatcounter is a lightweight, privacy‑friendly, open‑source analytics tool by Martin Tournoij. It sets no cookies, doesn’t track individuals, and provides enough stats to understand real site usage (pageviews, referrers, devices, etc.).

  • Open source (AGPL), self‑hostable or hosted service.
  • Cookieless and privacy‑compliant in most cases.
  • Tiny script (~3–7 KB gzipped) and fast.
  • Simple API to count pageviews or events manually.
Bonus

Compared to other free analytics solutions, it lets you exclude your IP and more.

Official site: GoatCounterDocumentation

Basics: add the script

The minimal method (hosted by Goatcounter) is to add this script to your pages:

<script
data-goatcounter="https://yourSite.goatcounter.com/count"
async
src="https://gc.zgo.at/count.js"
></script>

For a self‑hosted Goatcounter, the data-goatcounter attribute must point to your /count endpoint:

<script
data-goatcounter="https://stats.yourdomain.tld/count"
async
src="https://stats.yourdomain.tld/count.js"
></script>

Noscript tip (counts a view even if JS is disabled):

<noscript>
<img src="https://yourSite.goatcounter.com/count?p=/" alt="" />
</noscript>

Essential options

Options are set via a global window.goatcounter object before loading the script, or via the <script> tag’s data-goatcounter attribute.

  • data-goatcounter: Counting endpoint URL (e.g. https://abc.goatcounter.com/count).
  • window.goatcounter.no_onload (bool): disable automatic counting on initial load. Useful for SPAs (like Docusaurus) so you call count() manually on each route change.
  • window.goatcounter.endpoint (string): explicitly set the counting endpoint if you don’t use data-goatcounter.
  • window.goatcounter.allow_local (bool): allow (or not) local counts (by default, local is ignored).
  • window.goatcounter.path (function): customize the path being counted (include query/hash if desired).
  • window.goatcounter.count({ ... }): JS API to count a pageview or an event manually.
    • path: path to count (e.g. "/docs/intro").
    • title: page/event title.
    • referrer: referrer to use.
    • event: true to record an event (CTA click, etc.).

(Source: official documentation)

warning

For single‑page sites like Docusaurus (SPA), you need a bit of setup to avoid double counting and to track client‑side page changes.

Integration with Docusaurus (3 approaches)

1- Basic add via docusaurus.config.js

For a simple static site (no route change tracking), add the script in docusaurus.config.js.

Basic:

docusaurus.config.js

module.exports = {
// ... your other configuration ...
scripts: [{
src: "https://gc.zgo.at/count.js",
async: true,
'data-goatcounter': 'https://myProject.goatcounter.com/count',
}, ],
};

Note that by default, localhost is not counted by Goatcounter.

Production‑only variant:

docusaurus.config.js

const isProd = process.env.NODE_ENV === 'production';

module.exports = {

scripts: isProd ?
[{
content: 'window.goatcounter = { no_onload: true };'
},
{
src: 'https://gc.zgo.at/count.js',
async: true,
'data-goatcounter': 'https://yourSite.goatcounter.com/count',
},
] :
[],
};

Pros: simple and immediate. Limitation: counts only the initial load; in a SPA, client‑side page changes won’t be tracked automatically.

2- Minimal SPA tracking with a client module

Docusaurus can run JS on each route change via onRouteDidUpdate.

Files structure

📁src
📁utils
goatcounter.js
src/utils/goatcounter.js

export function onRouteDidUpdate({
location
}) {
if (typeof window === 'undefined') return;
const gc = window.goatcounter;
if (!gc || typeof gc.count !== 'function') return;

// full path (include query/hash if useful)
const path = location.pathname + location.search + location.hash;
gc.count({
path,
title: document.title
});
}

Then reference this module on the client side in docusaurus.config.js:

docusaurus.config.js
const isProd = process.env.NODE_ENV === 'production';

module.exports = {

scripts: isProd ?
[{
content: 'window.goatcounter = { no_onload: true };'
},
{
src: 'https://gc.zgo.at/count.js',
async: true,
'data-goatcounter': 'https://yourSite.goatcounter.com/count',
},
] :
[],
clientModules: [require.resolve('./src/utils/goatcounter.js')],
};

Result: the initial view and all internal navigations are counted properly.

Choose a pattern for the initial view (A/B)

Two equivalent approaches — pick just one to avoid duplicates:

  • Pattern A — no_onload: true + count everywhere via onRouteDidUpdate (snippet above). Advantage: everything is driven by your code.
  • Pattern B — Let Goatcounter auto‑count the initial view, then count only subsequent SPA navigations.

Example Pattern B (with small dedup):

src/utils/goatcounter.js
let lastCountedPath;
export function onRouteDidUpdate({ location, previousLocation }) {
if (typeof window === 'undefined') return;

// Ignore the very first render (already auto‑counted by GoatCounter)
if (!previousLocation) return;

const gc = window.goatcounter;
if (!gc || typeof gc.count !== 'function') return;

const path = `${location.pathname}${location.search}${location.hash}`;
if (path === lastCountedPath) return;
lastCountedPath = path;

gc.count({ path, title: document.title });
}

And in config, don’t set no_onload: true; just inject the script:

docusaurus.config.js
scripts: [
{
src: 'https://gc.zgo.at/count.js',
async: true,
'data-goatcounter': 'https://yourSite.goatcounter.com/count',
},
],

3- Small Docusaurus plugin (optional, more structured)

If you prefer to encapsulate the logic, create a small plugin that injects the script and handles navigations. Conceptually, it exposes getClientModules() and a client module with onRouteDidUpdate. This makes reuse (multi‑projects) and options (endpoint, allow_local, etc.) easier.

Example config‑side API (idea):

docusaurus.config.js
plugins: [
[
require.resolve('./plugins/goatcounter'),
{
endpoint: 'https://yourSite.goatcounter.com/count',
no_onload: true,
allow_local: false,
},
],
],

Custom events (clicks, CTAs, etc.)

You can also count events (in addition to pageviews):

// Example: sign‑up button click
window.goatcounter?.count({
path: '/event/signup-cta',
title: 'Signup CTA click',
event: true,
});

Best practices:

  • Prefix events with /event/ or use a clear scheme.
  • Avoid sending personal data (PII).
  • Throttle/deduplicate on the UI side if needed to avoid spam.

Self‑hosting vs hosted service

  • Hosted service: get started in minutes, no infrastructure to manage.
  • Self‑hosting: full control of data, custom domains/CDN; you need to deploy the Goatcounter app and expose /count.js and /count.

In both cases, the Docusaurus integration is the same (only the URL changes).

Verify everything works

  1. Run the site in production mode (build + serve) to test conditional scripts.
  2. Open the browser console: no Goatcounter‑related errors.
  3. Navigate between several pages: you should see hits arriving in your Goatcounter dashboard.
  4. Trigger a custom event (see section above) and validate it appears.
note

By default, Goatcounter ignores the local environment; enable allow_local if you want to test without deploying.

If everything is OK, you should see your multi‑page navigation appear in the Goatcounter dashboard.

GoatCounter dashboard

Exclude your IP address

In the Goatcounter dashboard, go to SettingsMain and add your IP address under "Ignore these IP addresses" so your own visits aren’t counted.

Happy Goatcounter integration!


This article is part of the SEO & Analytics series:

Related posts

Back to top