Your generator already has one file that renders the head of every page. That is the entire install: one cookieless script tag, a production check, and a two-minute test.
Every static site generator has exactly one file that renders the document head for all pages. Put the GhostMetrics tag there once and every page you build inherits it. There is no plugin to install in Hugo, Jekyll, Astro or Eleventy, because there is no plugin: the tool is a script tag.
baseof.html. Jekyll: _includes/head.html or _layouts/default.html. Astro: the <head> of your layout component, with is:inline. Eleventy: the base layout in your includes folder.localhost previews and branch builds do not mix into your numbers. Each generator has a standard check, shown below.A site built by Hugo, Jekyll, Astro or Eleventy is a folder of HTML files. Analytics on that kind of site does not need a module, a build step or a data layer. It needs one line in the layout that produces the head. The generator copies that line into every page at build time, the browser loads the script, and the script reports the pageview.
That is the whole mechanism. The tag is <script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID">, about 3 KB compressed on the wire (10 KB uncompressed), served from Cloudflare's edge. It sets no cookies, uses no localStorage, stores no IP addresses (they are hashed), and keeps no cross-session identifier. Global Privacy Control and Do Not Track are honored, which means nothing is collected for those visitors. Its only browser storage is a per-tab sessionStorage counter and any first-touch UTM, cleared when the tab closes.
This page is the generator-specific companion to how to add analytics to a static site, which covers the general pattern. Here the focus is narrower: which file to edit in each of the four generators, how to render the tag only in production, how to confirm it works, and how to add click events when your content is written in Markdown.
If your site is deployed to a specific host, the tag works the same on all of them. GitHub Pages, Netlify, Vercel and Cloudflare Pages only serve the files your generator produced. For host-specific context, see Netlify analytics alternatives and Vercel analytics alternatives.
The table lists the conventional location in each generator. These are well-known patterns, but themes and starter templates rearrange files, so open your own project and find the file that contains the opening <head> tag (or the partial that renders it).
| Generator | Template language | Conventional file | Production check |
|---|---|---|---|
| Hugo | Go templates | layouts/_default/baseof.html, usually calling a head partial such as layouts/partials/head.html | hugo.IsProduction |
| Jekyll | Liquid | _layouts/default.html, or _includes/head.html if your layout includes one | jekyll.environment == "production" |
| Astro | Astro components (JSX-like) | The layout component in src/layouts/ that renders <html> and <head> | import.meta.env.PROD |
| Eleventy | Nunjucks, Liquid, WebC and others | The base layout in your includes folder, often _includes/base.njk | An environment variable you expose through a data file |
Two rules apply everywhere. Put the tag in the shared layout, never in an individual page, or you will track some pages and silently miss others. And put it inside <head>: the defer attribute means it does not block rendering, and GhostMetrics does not need to be last in the body.
Hugo builds every page from a base template, conventionally layouts/_default/baseof.html, with blocks that individual templates fill in. Most themes keep the head contents in a partial. Find the partial your base template calls inside <head> (look for {{ partial "head.html" . }} or a similar line) and add the tag there. If you use a theme from a Git submodule, copy the partial into your own layouts/partials/ folder first: Hugo prefers files in your project over the theme's, so your change survives theme updates. Newer Hugo releases have reorganized the layout lookup rules, so check the current Hugo docs if your folder names differ from the ones here.
<!-- layouts/partials/head.html -->
{{ if hugo.IsProduction }}
<script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
{{ end }}
Jekyll layouts live in _layouts/ and shared fragments in _includes/. In the plain default setup, _layouts/default.html contains the whole document, including the head. Many themes split the head out into _includes/head.html and pull it in with {% include head.html %}. Edit whichever file holds the head. With a gem-based theme, copy the theme's file into the same path in your project to override it, which is the standard Jekyll mechanism for customizing theme files.
<!-- _includes/head.html -->
{% if jekyll.environment == "production" %}
<script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
{% endif %}
Jekyll treats the environment as development unless you set JEKYLL_ENV=production for the build. Some hosting setups set that for you and some do not, so confirm what your build command and host do before trusting the gate.
Astro projects usually have a layout component, often src/layouts/Layout.astro or BaseLayout.astro, that every page wraps itself in. The tag goes inside that component's <head>. Mark it is:inline: without it, Astro treats a script tag as something to process and bundle, and the data-site attribute that identifies your site may not survive the build. is:inline tells Astro to emit the tag as written.
---
// src/layouts/Layout.astro
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{Astro.props.title}</title>
{import.meta.env.PROD && (
<script is:inline defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
)}
</head>
<body><slot /></body>
</html>
import.meta.env.PROD is true in the output of astro build and false under astro dev. If you use Astro's view transitions or another client-side router, test navigation between pages with the real-time dashboard open, and check the current Astro docs for how scripts behave across transitions. GhostMetrics counts pushState and popstate navigation as new pageviews, so the usual routing pattern is covered on the tool's side.
Eleventy does not impose a layout structure, so look in your includes folder (default _includes/) for the base layout that your other layouts chain to, commonly base.njk, layout.njk or base.liquid. The tag goes in its head. Eleventy has no built-in production flag in templates (since v2, eleventy.env.runMode tells a build from a dev server, but not production from staging), so the common pattern is to read an environment variable in a global data file and test it in the layout.
// _data/env.js
module.exports = {
production: process.env.ELEVENTY_ENV === "production"
};
<!-- _includes/base.njk -->
{% if env.production %}
<script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
{% endif %}
Then build with the variable set, for example ELEVENTY_ENV=production npx @11ty/eleventy on macOS and Linux (shell syntax differs on Windows). The variable name is a convention you choose, not something Eleventy defines. If your project uses ES modules, the data file syntax is different; check the current Eleventy docs for data file formats.
A script tag in a layout renders on every build unless you say otherwise. That includes your local dev server, preview deploys for pull requests and any staging site. Each of those visits can end up in the dashboard of the site you registered, which inflates counts and pollutes top pages with draft URLs.
The production checks in the table all work the same way: the generator knows whether it is running a dev server or a full build, and the layout only emits the tag in the latter. A few practical notes:
hugo.IsProduction or import.meta.env.PROD will be true. If you want previews excluded, gate on something more specific, such as a variable your host sets only on the main branch deploy, or compare site.BaseURL in Hugo or site.url in Jekyll against your real domain.Gating is about data quality, not privacy. GhostMetrics itself collects no personal data either way; it simply cannot tell a developer's refresh from a reader's visit, so keeping dev builds from rendering the tag is the clean fix.
The tag fails quietly. A typo in the site ID or a tag in the wrong partial produces no error, just an empty dashboard. This sequence catches the common problems on day one.
public/ for Hugo, _site/ for Jekyll and Eleventy, dist/ for Astro by default). Search the built HTML for gm.js. It should appear in the head of every page, not only the home page. A quick grep -rL --include='*.html' gm.js over the output folder lists the pages that are missing it.data-site="YOUR-SITE-ID" with your real ID. In Astro, a missing attribute means is:inline is absent.gm.js: it should load from ghostmetrics.nullagency.io.Cookieless analytics and a banner-free page go together, which is why people adding this to a Markdown blog often remove a consent prompt at the same time. If that is your plan, read web analytics without a consent banner first, and keep in mind that other embeds, ads or trackers on the page carry their own rules.
Static sites are mostly content, and content is mostly Markdown. GhostMetrics custom events work declaratively: add data-gm-event="signup-click" to any element and clicks on it are recorded. One delegated listener on the document handles every element, so nothing needs to be re-bound after a client-side navigation or a modal opens.
In a layout or component, that is a one-attribute change:
<a href="/newsletter" class="btn" data-gm-event="newsletter-cta">Subscribe</a>
Inside a Markdown file the question is whether your generator lets raw HTML through. Each handles it differently, and defaults have changed over time, so check the current docs for your version:
unsafe option in the Markdown renderer configuration. Alternatively, wrap the element in a shortcode, which is a Hugo template you call from content and which sidesteps the setting..md files, and MDX files let you use components, so a small wrapper component can add the attribute.For events that are not clicks, such as a form submission handled in JavaScript, call the function directly:
window.ghostmetrics.track('newsletter-submit', { section: 'footer' });
Only section is stored as metadata. Events also carry the page path and any session UTM. There is no numeric value or revenue field and no per-visitor funnel across sessions. Event names show up in the Custom Events panel as name, count and last fired, so a consistent naming scheme (noun-action, lowercase) keeps it readable. The custom events guide covers naming patterns in more depth.
Because the integration is a script tag, anything a generator-specific plugin would provide is not there. Be clear about that before you pick it.
The upside is the same fact stated the other way: because nothing is bundled into your build, there is nothing to upgrade when the generator releases a new major version, and nothing breaks when you switch generators. Moving from Jekyll to Eleventy means moving one tag from one layout to another.
If you are comparing approaches rather than tools, this is how the options sit on a static site.
| Approach | Build changes | Cookies / banner | Custom events | Fit |
|---|---|---|---|---|
| One script tag (GhostMetrics) | None; one layout edit | No cookies; no banner needed for the tool itself | Yes, data-gm-event or ghostmetrics.track | Any generator, any host |
| Google Analytics 4 via gtag.js | One layout edit plus a consent flow | Sets cookies by default; consent generally required in the EU and UK | Yes | Right when you need Google Ads attribution or ecommerce funnels |
| Host-level analytics (Netlify, Vercel) | None, or a package for some hosts | Varies by product | Varies; log-based tools have none | Tied to one host |
| Self-hosted (Umami, Plausible CE) | One layout edit plus a server to run | Cookieless | Yes | Right when you must own the stack |
For a longer comparison of the field, see cookieless tracking explained, which covers how these tools identify visits without cookies.
GhostMetrics is cookieless web analytics for Hugo, Jekyll, Astro, Eleventy or hand-written HTML: one script tag in the shared head, no cookies, no localStorage, no stored IP addresses. The free plan is free forever with no card: one website, unlimited pageviews, the real-time dashboard, every view, full history, plus raw CSV or JSON export on every plan. We use it on nullagency.io.
Several sites or a public dashboard URL? Pro is $9.99/mo flat with unlimited websites (fair-use ceiling of 250) and a 30-day free trial; a card starts the trial, nothing is charged for 30 days, and you can cancel anytime. Custom events work with data-gm-event="signup-click" on any element or window.ghostmetrics.track('name', { section: 'pricing' }) from code.
Disclosure: GhostMetrics is built by Null Agency and we use it on this site, so we're not a neutral party about our own product. We've kept the claims to what the free and paid plans actually do and pointed to self-hosted tools and Google Analytics where they genuinely fit better. Generator behavior changes between releases; check each project's current documentation before relying on a file path or option named here.