Skip to main content

GoatCountViews Component

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

Developer Development License: MIT AI 65%

After adding Goatcounter to my blog, I wanted to display view counts for each page. My goals were to:

  • Display the number of views for a page
  • In the article list, show the views for each post individually
  • Avoid HTML injection and the "flash" of unparsed content (common when using JavaScript or the iframe)

So I created this component using GoatCounter's JSON API.

Official Goatcounter docs for this: View counts

Why I chose the JSON API instead of visit_count()

  • Inline <script> tags aren’t well supported by MDX/Docusaurus (acorn parsing errors).
  • visit_count() injects raw HTML, which causes a visible “flash” before we can parse/format it. here's what flashes for 1–3 seconds
  • The JSON API only returns data (no HTML). We keep full control of the rendering: icon, localized formatting, accessibility, etc.

Conclusion: Using JSON avoids third‑party HTML injection, removes the flash, and keeps the rendering fully under your control.

Load GoatCounter globally

Learn more here: Goatcounter Analytics

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

This script records visits. The JSON API is only used to read/display them.

React component: JSON fetch + accessible rendering

The component

  • Detects the base https://yourName.goatcounter.com from the data-goatcounter attribute injected into the page;
  • Determines the target path (prop path takes precedence, otherwise it uses the current path);
  • Calls /counter/<path>.json and formats according to navigator.language;
  • Renders an “eyes” icon (twemoji) + the number, with aria-label="views".

Logic

On errors or blockers:

  • Display rule: if total views < 100 → show “New!”, otherwise show the formatted number (useful for young blogs).
  • If the JSON endpoint returns 404 (page never seen by Goatcounter in prod), also show “New!”.
  • On localhost, Goatcounter doesn’t count, but will display whatever is already stored.
  • On network/ad‑blocker errors (JSON request fails), the fallback shows 0 in dev and nothing in prod; if the return is null we render nothing.

The 100‑views rule can be adjusted to your needs. After a chat with a Belgian meerkat, it’s true that showing low view counts can be perceived as a negative signal when the numbers are relatively small.

Full component code

Files structure

📁src
📁components
📁GoatCountViews
index.js

Code

import React, {useEffect, useState} from 'react';
import LogoIcon from '@site/src/components/LogoIcon';

export default function GoatCounterViews({path: explicitPath}) {
const [text, setText] = useState(null);

useEffect(() => {
let done = false;
const controller = new AbortController();

const locale = typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en-US';
const format = n => new Intl.NumberFormat(locale).format(n);

const normalizePath = (p) => {
if (!p) return p;
try {
const u = new URL(p, location.origin);
return u.pathname + u.search;
} catch {
return p;
}
};

const getBase = () => {
const el = document.querySelector('script[data-goatcounter]');
const data = el?.getAttribute('data-goatcounter');
if (data) {
try {
const u = new URL(data); // https://SUB.goatcounter.com/count
return `${u.protocol}//${u.host}`; // https://SUB.goatcounter.com
} catch {}
}
return null;
};

const getPath = () => {
if (explicitPath) return normalizePath(explicitPath);
try {
const p = window.goatcounter?.get_data?.()?.p;
if (p) return p;
} catch {}
return location.pathname;
};

(async () => {
try {
const base = getBase();
const path = getPath();
if (!base || !path) throw new Error('missing base or path');

const res = await fetch(`${base}/counter/${encodeURIComponent(path)}.json`, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
cache: 'no-store',
signal: controller.signal,
});
// 404 case: unknown path in GoatCounter -> page never seen in production
if (res.status === 404) {
const isLocal = /^(localhost|127\.0\.0\.1|::1)$/i.test(location.hostname);
if (!done) setText('New!');
done = true;
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();

const digits = String(json?.count ?? '').replace(/\D/g, '');
if (digits) {
const value = Number(digits);
if (done) return;
// Rule: < 100 => "New!", otherwise formatted value
setText(value < 100 ? 'New!' : format(value));
done = true;
return;
}
throw new Error('empty count');
} catch (e) {
// Abort: do nothing
if (e?.name === 'AbortError') return;
// Other errors (network, blocking, CORS, etc.)
setTimeout(() => {
if (done) return;
const isLocal = /^(localhost|127\.0\.0\.1|::1)$/i.test(location.hostname);
// In dev: show 0 for visibility; in prod: render nothing (null) when unavailable/blocked
setText(isLocal ? format(0) : null);
done = true;
}, 300);
}
})();

return () => {
done = true;
controller.abort();
};
}, [explicitPath]);

if (text == null) return null;

return (
<span aria-label="views" title="views">
<LogoIcon name="twemoji:eyes" size={12} />{' '}{text}
</span>
);
}

Usage in an MDX page

In a standard post

---
title: "View counter"
---

Seen by: <GoatCounterViews /> meerkats

The component will display the views for the current page.

Seen by: 2600 meerkats

Post list

On a listing page (e.g., Blog)

If you display multiple posts, you must request the counter for each post by passing its exact path. For example, in a swizzled component (header of a blog post item):

Provide the path path={metadata.permalink} for each post

src/theme/BlogPostItem/Header/index.js
import GoatCounterViews from '@site/src/components/GoatCountViews';

export default function BlogPostItemHeader({metadata, ...props}) {
return (
<>
{/* ...existing code... */}
<GoatCounterViews path={metadata.permalink} />
</>
);
}

This way, each card shows the views of its own article, not the views of the listing page.


This article is part of the SEO & Analytics series:

Related posts

Back to top