How to Add Analytics to a Static Site: Astro, Hugo, Next.js and More in 2026

One script tag in your head partial. No server, no build plugin, no cookie banner. Here's exactly where that tag goes in six frameworks, and how to verify it actually fired.

By Null Agency · Updated September 7, 2026 · Written for developers shipping Astro, Hugo, Eleventy, Jekyll, Next.js, or SvelteKit who want traffic numbers without Google Analytics or a consent banner

TL;DR - the whole setup in five lines

A static site has no server to install anything on, which is exactly why analytics for static sites is simpler than everyone assumes. You're not adding a tracking pixel to a CMS plugin ecosystem - you're adding one <script> tag to the single head partial every page already shares.

The rest of this guide is the exact head-partial location for six frameworks, plus verification, SPA routing, dev-visit hygiene, custom events, and an honest look at what your host's built-in analytics actually cover.

The one-script pattern behind every static site

Every static site generator - regardless of templating language - compiles down to the same thing: HTML files with a shared <head> that every page includes. That shared head is the only place you need to touch. You're not configuring a plugin, registering a webhook, or opening a port; you're pasting one line into a file you already have.

The reason this works with zero server-side effort is that the analytics service lives on a completely different host from your site. GhostMetrics, for example, runs at ghostmetrics.nullagency.io - your static host never talks to it directly. The browser loads your HTML from GitHub Pages or Netlify, then that HTML tells the browser to fetch a small script (about 3 KB compressed on the wire) from a different domain, and that script reports the pageview from the visitor's browser. Your build pipeline, your host, and your deploy process stay completely unaware analytics exists.

That's the whole architecture: one script tag, one external host, zero backend. The only real work is finding where your framework's shared head lives, and that's different enough across tools that it's worth walking through each one.

Where the head partial lives, framework by framework

The tag itself never changes. Only its home does. Here's the worked example with GhostMetrics; swap in Plausible's, Fathom's, or Umami's own script tag and the location is identical - it's the same one-tag pattern regardless of which cookieless tool you pick.

Hugo - layouts/partials/head.html

Hugo compiles every page through a partial you include from your base template. Drop the tag there, gated by Hugo's own production flag so a local hugo server never fires it:

<head>
  <meta charset="utf-8">
  <title>{{ .Title }} - {{ .Site.Title }}</title>
  {{ if hugo.IsProduction }}
  <script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
  {{ end }}
</head>

hugo.IsProduction is true when you build with hugo --environment production (Hugo's default for a plain hugo build) and false for hugo server, so this one condition handles the dev/prod split for you.

Astro - a Layout.astro component's <head>

Astro projects typically share one Layout.astro that every page wraps itself in. Use Vite's build-time env flag in the frontmatter:

---
const { title } = Astro.props;
const isProd = import.meta.env.PROD;
---
<head>
  <meta charset="utf-8" />
  <title>{title}</title>
  {isProd && (
    <script is:inline defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
  )}
</head>

import.meta.env.PROD is true during astro build and false during astro dev - the same mechanism Vite gives every framework built on it. The is:inline directive matters: without it Astro processes the tag as a module, and analytics scripts that read document.currentScript to find their site ID — GhostMetrics included — get null and silently do nothing.

Eleventy (11ty) - a base layout

Eleventy exposes a run mode (process.env.ELEVENTY_RUN_MODE) but not a production flag as such, so the clearest option is passing your own in as global data, then branch on it in your base layout (shown here in Nunjucks):

// .eleventy.js
module.exports = function (eleventyConfig) {
  eleventyConfig.addGlobalData("env", process.env.ELEVENTY_ENV || "development");
};
<!-- _includes/base.njk -->
<head>
  <meta charset="utf-8">
  <title>{{ title }}</title>
  {% if env == "production" %}
  <script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
  {% endif %}
</head>

Set ELEVENTY_ENV=production in your deploy command (most hosts let you set build-time environment variables in their dashboard) and local builds default to "development".

Jekyll - _includes/head.html

Jekyll's shared head is a literal include file, and Jekyll already tracks an environment for you:

<head>
  <meta charset="utf-8">
  <title>{{ page.title }}</title>
  {% if jekyll.environment == "production" %}
  <script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
  {% endif %}
</head>

jekyll.environment reads the JEKYLL_ENV variable and defaults to development, so build with JEKYLL_ENV=production bundle exec jekyll build for a production deploy - which is also exactly how GitHub Pages' own Jekyll build behaves.

Next.js App Router - app/layout.tsx

For a static export (output: 'export' in next.config.js), the root layout is still the one place every route shares. Use next/script with strategy="afterInteractive" so it doesn't block hydration:

import Script from 'next/script'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        {process.env.NODE_ENV === 'production' && (
          <Script
            src="https://ghostmetrics.nullagency.io/gm.js"
            data-site="YOUR-SITE-ID"
            strategy="afterInteractive"
          />
        )}
      </body>
    </html>
  )
}

strategy="afterInteractive" loads the script after the page becomes interactive rather than blocking the initial render - the right tradeoff for something that only reports a pageview, not something the page depends on.

SvelteKit - src/app.html

SvelteKit's app.html is the raw HTML shell every route is injected into, which makes it the obvious place for the tag:

<head>
  %sveltekit.head%
  <script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
</head>

One honest caveat: app.html only supports %sveltekit.*% placeholder substitution, not conditionals, so it can't branch on an environment variable. If you want the dev/prod split other frameworks get for free, move the tag into your root src/routes/+layout.svelte instead, where you have real JavaScript and Vite's import.meta.env.PROD flag:

<svelte:head>
  {#if import.meta.env.PROD}
    <script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
  {/if}
</svelte:head>

Verify the install, then handle client-side routing

A script tag that's technically deployed and a script tag that's actually firing are two different things - a typo in the site ID, a script blocked by an ad blocker, or a partial that isn't included on every page will all deploy cleanly and record nothing. Always verify before you trust the dashboard:

  1. Deploy the change. Push to your static host and wait for the build to finish - GhostMetrics only counts real production traffic, not your local dev server, if you gated the tag correctly above.
  2. Open the real-time view. Log into your GhostMetrics dashboard and pull up the real-time panel before you visit anything.
  3. Visit your own site in a new tab. Load the homepage, then click through to a second page.
  4. Watch for your visit. If it appears within a few seconds, the install is correct. If nothing shows up, check that the script actually rendered in your page source (view-source, not just the editor) and that the site ID matches what your dashboard expects.
  5. If your site uses client-side routing, click a route link instead of reloading. Astro islands, a Next.js client component, or SvelteKit's router all navigate without a full page load, so this step tells you whether route changes are being tracked at all.

That last step matters more than it looks. A standard analytics script only fires on a full page load - the browser event that a single-page-style route change never triggers. GhostMetrics's script listens for pushState and popstate calls specifically so that client-side navigation still counts as a new pageview, which covers Next.js's router, Astro's client-side view transitions, and SvelteKit's router without any extra code on your part. If you're comparing tools, this is one of the first things worth confirming for whichever one you pick - not every lightweight script hooks the History API by default. Our GA4 vs Plausible vs GhostMetrics comparison covers this and other tool-level differences if you're still deciding.

Keep dev visits out of your production numbers

Once analytics is live, your own testing traffic becomes noise in the dashboard - and it's worth being upfront that GhostMetrics has no "exclude my IP" toggle to clean that up after the fact. The reliable fix is prevention, not filtering: don't let the script render outside of production in the first place.

That's exactly what every snippet above already does. Hugo's hugo.IsProduction, Astro and SvelteKit's import.meta.env.PROD, Jekyll's jekyll.environment, Eleventy's custom env global, and Next.js's process.env.NODE_ENV are all build-time checks - the script tag simply doesn't exist in the HTML your dev server outputs, so there's nothing for your browser to load while you're working locally. It's a compile-time decision, not a runtime one, which means there's no flag to forget to flip before you deploy.

If you ever do need to test the tracking itself - confirming an event fires, say - the cleanest approach is a second site ID reserved for staging, so any noise lands in a dashboard you already expect to ignore. Worth knowing before you plan around it: the GhostMetrics free plan covers one site, so a second site ID means the $9.99/mo Pro plan. On free, the build-time gate above is the answer.

Track a button click with one data attribute

Pageviews tell you who showed up; a custom event tells you what they did once they got there. On a static site, the declarative approach is usually all you need - no extra JavaScript, just an attribute on the element you care about:

<button data-gm-event="newsletter-signup">Subscribe</button>

Any element with data-gm-event gets its clicks recorded automatically once the GhostMetrics script has loaded - a "Buy now" button, a docs page's "Copy code" action, an outbound link to your GitHub repo. This is one delegated click listener on the document, not a scan of the page at load, so it works the same in a true SPA: a button that Next.js or SvelteKit renders after a client-side route change is tracked without any re-binding on your side. If you need to attach a bit of context, or fire an event that isn't a click, the programmatic API takes an optional metadata object:

window.ghostmetrics.track('signup-click', { section: 'pricing' });

Worth knowing exactly what that stores, since it's easy to assume more than is there: the event carries the page path it happened on and any UTM parameters already in the session, plus whatever you put in the metadata object - but the only metadata field GhostMetrics actually stores is section. There's no numeric value or revenue field, and no funnel that stitches events together across separate visitor sessions. For "did this button get clicked more this week," it's exactly enough; for a multi-step revenue funnel, you'd need a heavier tool.

Cookieless means no banner, even on a docs or blog site

Static sites are disproportionately docs, blogs, and marketing pages - exactly the kind of site where a cookie consent banner feels absurd for what little cookies would even do. Cookie banners exist because non-essential cookies (tracking, advertising, cross-site identifiers) generally require consent under most privacy regimes. A cookieless analytics tool never sets that kind of cookie, so on a site with no ad pixels and no other cookie-setting embeds, there's frequently nothing to ask permission for.

GhostMetrics is cookieless end to end: no cookies, no localStorage, no stored IP addresses (they're hashed), and no personal data or cross-session identifier. It also honors Global Privacy Control and Do Not Track signals by collecting nothing at all when either is present. For the mechanics of how a tool counts visitors without a persistent identifier, see our plain-language cookieless tracking explained primer, and for the specific question of whether your setup still needs a banner, our do I need a cookie banner guide walks through the common edge cases (embeds, ads, third-party widgets) that can bring the requirement back even with a cookieless analytics tool installed.

Two honest caveats: this is general guidance, not legal advice, so confirm your own obligations before removing a banner - and GhostMetrics itself is not open source and not self-hostable; it's a hosted product on Cloudflare. If self-hosting or strict data residency is a hard requirement, Plausible and Umami are the better fit, and we say so below.

Comparison: what a static site can actually install

"Which analytics tool" is really a handful of qualitative tradeoffs once you're picking for a static site specifically - whether it needs a script at all, whether it's cookieless, and what the free tier actually gets you. Pricing details change, so treat the numbers below as a snapshot and check current pricing before deciding:

ToolScript neededCookielessCustom eventsFree tierPricing model
GhostMetricsYes, one tag (~3 KB)YesYes - data attribute or JS callFree forever, one site, unlimited pageviewsFlat $9.99/mo Pro for multiple sites + public dashboard
PlausibleYes, one tagYesYesNo permanent free tier (trial only) - check current pricingUsage-based, priced by monthly pageviews
FathomYes, one tagYesYesNo permanent free tier (trial only) - check current pricingUsage-based, priced by monthly pageviews
Umami (self-host)Yes, one small tagYesYesFree if you run your own serverOpen source; you cover your own hosting cost
Cloudflare Web AnalyticsYes, small beaconYesNo - page-level metrics onlyYes, freeFree
Host built-in analyticsVaries - Netlify log-based, Vercel script-basedVaries by hostNo or limitedVaries - often capped or a paid add-onBundled with hosting plan, or a separate paid add-on - check current pricing

That last row is the one people searching for a "Netlify Analytics alternative" usually hit first. Host built-ins vary more than people expect. Netlify Analytics reads server logs directly - no client script, so nothing to block, and it's sold as a paid add-on. Vercel Web Analytics goes the other way and does use a client-side script/package, included on plans with usage limits. Either way the report set is narrower than a dedicated tool's (limited breakdowns, little or no custom-event support) - check the current docs and pricing for whichever host you're on. GitHub Pages offers no built-in analytics at all, so it needs a third-party script by default. If open-source or self-hosting is a real requirement, Umami and Plausible remain the honest recommendation over anything hosted, including ours.

One script tag, verified in minutes: GhostMetrics free-forever

$0 forever1 siteUnlimited pageviewsSPA-awareNo consent banner

Static sites don't need a heavy analytics stack, and GhostMetrics is built for exactly this case - one script tag, no server, and it already hooks pushState/popstate for whatever client-side routing your framework uses. The free plan is free forever: one site, unlimited pageviews, real-time dashboard, and full history, cookieless with no consent banner to add.

Outgrow one site, or want a public shareable dashboard for a client or open-source project? Pro is $9.99/mo flat with a 30-day free trial - unlimited sites under one login and a public dashboard URL, and that's the entire difference. Raw event export to CSV or JSON is on every plan, including free. See real numbers before you sign up with the live demo - no signup wall.

Start free - no card, no banner See the live demo

FAQ

Do I need a backend or server to add analytics to a static site?
No. The analytics service lives on its own host - for GhostMetrics that's ghostmetrics.nullagency.io - so your static site only needs one script tag in the shared head partial. There's no server code, no API route, and no database to run yourself.
How do I add analytics to a site hosted on GitHub Pages?
GitHub Pages has no built-in analytics at all, so you need a third-party script regardless of which tool you pick. Add the same one script tag to your site's head template (Jekyll's _includes/head.html if you're using GitHub Pages' default build, or your static build tool's layout if you're pushing prebuilt HTML) and it works exactly like any other static host.
Will analytics still work with client-side routing in a React or Svelte app?
With GhostMetrics, yes - the script listens for pushState and popstate events, so a route change inside a single-page app registers as a new pageview without any extra code. Confirm this in the real-time dashboard by clicking between a couple of client-side routes and watching the pageview count tick up.
How do I avoid counting my own visits while developing locally?
GhostMetrics has no 'exclude my IP' toggle, so the reliable fix is to only render the script tag in production - gate it behind your framework's build-time environment flag (Hugo's hugo.IsProduction, Astro and SvelteKit's import.meta.env.PROD, Jekyll's jekyll.environment, or NODE_ENV in Next.js) so your local dev server never sends a pageview.
Do I need a cookie banner for a static blog or docs site?
Not if your analytics is cookieless and you're not running ad pixels or other cookie-setting embeds - a cookieless tool never sets the kind of cookie that requires consent in the first place. This is general guidance, not legal advice, so confirm your specific setup and jurisdiction before removing a banner.

Keep reading

Cookieless Tracking Explained

How counting works without cookies

Track Custom Events Without GA

Clicks, signups and downloads — real code

Cloudflare Web Analytics Alternatives

What the free tool lacks, and what to use instead

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 here to what the free and paid plans actually do, hedged pricing we can't verify in real time for competitors, and pointed to open-source options (Plausible, Umami) where they fit better. Nothing here is legal advice; confirm your own privacy and cookie obligations for your jurisdiction before removing a consent banner.