Hugo, Jekyll, Astro and Eleventy Analytics Without Google

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.

By Null Agency · Updated October 20, 2026 · Written for developers running Hugo, Jekyll, Astro or Eleventy sites

TL;DR - one tag, one shared layout, production only

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.

Why a static site needs less than you think

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.

Where the tag goes in each generator

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).

GeneratorTemplate languageConventional fileProduction check
HugoGo templateslayouts/_default/baseof.html, usually calling a head partial such as layouts/partials/head.htmlhugo.IsProduction
JekyllLiquid_layouts/default.html, or _includes/head.html if your layout includes onejekyll.environment == "production"
AstroAstro components (JSX-like)The layout component in src/layouts/ that renders <html> and <head>import.meta.env.PROD
EleventyNunjucks, Liquid, WebC and othersThe base layout in your includes folder, often _includes/base.njkAn 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

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

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

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

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.

Production-only gating, and why it matters

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:

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.

Verify the install in five steps

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.

  1. Build in production mode and open the output folder (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.
  2. Check the attribute survived. In the built file, the tag should still carry data-site="YOUR-SITE-ID" with your real ID. In Astro, a missing attribute means is:inline is absent.
  3. Deploy, then open the live site in a fresh browser tab. In the browser's network panel, filter for gm.js: it should load from ghostmetrics.nullagency.io.
  4. Watch the real-time dashboard. Your visit should appear within moments. Click through to a second page and confirm that appears too.
  5. If nothing shows up, work through the short list: the tag is in a partial that does not render on that page type; the production check evaluated to false on your host's build; the site ID is wrong; your browser sends Global Privacy Control or Do Not Track, which GhostMetrics honors by collecting nothing; or a content blocker in your own browser is stopping the request. Test in a clean profile with extensions off before changing templates.

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.

Custom events from Markdown content

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:

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.

What GhostMetrics does not do on a generator-built site

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.

Choosing a method for your generator

If you are comparing approaches rather than tools, this is how the options sit on a static site.

ApproachBuild changesCookies / bannerCustom eventsFit
One script tag (GhostMetrics)None; one layout editNo cookies; no banner needed for the tool itselfYes, data-gm-event or ghostmetrics.trackAny generator, any host
Google Analytics 4 via gtag.jsOne layout edit plus a consent flowSets cookies by default; consent generally required in the EU and UKYesRight when you need Google Ads attribution or ecommerce funnels
Host-level analytics (Netlify, Vercel)None, or a package for some hostsVaries by productVaries; log-based tools have noneTied to one host
Self-hosted (Umami, Plausible CE)One layout edit plus a server to runCookielessYesRight 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.

One tag in your base layout. No plugin, no build step.

Free forever, no card1 siteUnlimited pageviewsAbout 3 KB compressed

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.

Start free, no card See the live demo

FAQ

Where does the analytics script go in a Hugo site?
In the head of your base template. Hugo themes usually render the head through a partial such as head.html, called from layouts/_default/baseof.html. Put the single script tag in that partial, wrapped in a production check, so every page inherits it. Check the current Hugo docs if your theme uses the newer layout structure.
Do I need a plugin or integration for Jekyll, Astro or Eleventy?
No. GhostMetrics has no plugin or integration for any static site generator. It is one script tag in the head, so the only generator-specific work is choosing the right layout or partial and, optionally, gating the tag to production builds.
Why does my Astro script lose its data-site attribute?
Astro processes plain script tags in its build pipeline, which can rewrite or bundle them. Adding is:inline tells Astro to leave the tag exactly as written, so the data-site attribute reaches the page. Check the current Astro docs for how it treats script tags.
How do I keep local development visits out of my numbers?
Wrap the tag in your generator's production check: hugo.IsProduction in Hugo, jekyll.environment in Jekyll, import.meta.env.PROD in Astro, and an environment variable exposed through a data file in Eleventy. The tag is then only rendered in production builds.
Can I track clicks on a Markdown-based page?
Yes, if the generator lets raw HTML through Markdown. Put data-gm-event on an HTML link or button inside the content. Some generators restrict raw HTML by default, Hugo's default Markdown renderer among them, so check your configuration before relying on it.

Keep reading

Add Analytics to a Static Site

The general pattern, host by host

Custom Events Without GA

Naming patterns and the two ways to fire

Analytics Without a Consent Banner

What cookieless changes for EU and UK visitors

GhostMetrics Live Demo

The real dashboard, live data

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.