GoatCountViews Component
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
///...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.comfrom thedata-goatcounterattribute injected into the page; - Determines the target path (prop
pathtakes precedence, otherwise it uses the current path); - Calls
/counter/<path>.jsonand formats according tonavigator.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
nullwe 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
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
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:
- Plugin Simple Analytics
- GoatCounter analytics
- GoatCountViews Component
Related posts

Plugin Simple Analytics
September 14, 2025

GoatCounter analytics
October 24, 2025
