GoatCounter analytics
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.
Compared to other free analytics solutions, it lets you exclude your IP and more.
Official site: GoatCounter — Documentation
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 callcount()manually on each route change.window.goatcounter.endpoint(string): explicitly set the counting endpoint if you don’t usedata-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: trueto record an event (CTA click, etc.).
(Source: official documentation)
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:
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:
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
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:
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 viaonRouteDidUpdate(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):
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:
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):
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.jsand/count.
In both cases, the Docusaurus integration is the same (only the URL changes).
Verify everything works
- Run the site in production mode (build + serve) to test conditional scripts.
- Open the browser console: no Goatcounter‑related errors.
- Navigate between several pages: you should see hits arriving in your Goatcounter dashboard.
- Trigger a custom event (see section above) and validate it appears.
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.

Exclude your IP address
In the Goatcounter dashboard, go to Settings → Main 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:
- Plugin Simple Analytics
- GoatCounter analytics
- GoatCountViews Component
Loading comments…
Related posts

Plugin Simple Analytics
September 14, 2025

GoatCountViews Component
October 26, 2025
