Skip to content

Page View Counter

Overview

A page-view counter that records a view for the page being read and displays the count in the article meta row, next to the reading time:

Zer0-Mistakes Team  β€’  August 15, 2026  β€’  4 min read  β€’  πŸ‘ 12 views

The theme is a static site, so there is no server to count on by default. The feature is therefore built around two providers, and the honest one is the default:

Provider Where the count lives What the number means Needs
local (default) This visitor's localStorage "How often you opened this page" Nothing. Works on GitHub Pages, offline, in a fork.
remote An HTTP endpoint you control "How often anyone opened this page" A counter URL that returns JSON.

There is deliberately no third "guess a global number client-side" mode β€” a static site cannot know a site-wide total without asking something, and a counter that quietly invents one is worse than no counter.


Component Diagram

_config.yml                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  page_views:                   β”‚ _includes/components/          β”‚
    enabled: true      ────────▢│   page-views-init.html         β”‚
    provider: local             β”‚   (once, from core/head.html)  β”‚
    dedupe: session             β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
    respect_dnt: true           β”‚  β”‚ <script id=…Config> JSON β”‚  β”‚
    ...                         β”‚  β”‚ <script defer src=…js>   β”‚  β”‚
                                β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                β”‚ reads config
                                                β–Ό
   _layouts/article.html         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     {% include components/      β”‚ assets/js/page-views.js        β”‚
        page-views.html          β”‚  1. privacy gates (DNT/GPC/    β”‚
        separator=true %}        β”‚     consent)                   β”‚
              β”‚                  β”‚  2. record current page view   β”‚
              β”‚  renders         β”‚  3. render every badge         β”‚
              β–Ό                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   <span class="page-views"                      β”‚ fills + unhides
         data-page-views="/url/" hidden> β—€β”€β”€β”€β”€β”€β”€β”€β”˜

Configuration

page_views:
  enabled         : true      # Master switch for tracking + display
  provider        : 'local'   # 'local' | 'remote'
  track           : true      # false = display known counts, never record new ones
  dedupe          : 'session' # 'session' (one view per page per session) | 'never'
  max_entries     : 500       # Cap on locally stored paths (oldest evicted first)
  label_one       : 'view'
  label_other     : 'views'
  respect_dnt     : true
  respect_gpc     : true
  require_consent : false
  endpoint        : ''        # remote only
  method          : 'POST'    # remote only
  count_key       : 'views'   # remote only β€” dotted paths allowed ('data.views')
  fallback_local  : false     # remote only β€” use the local counter if the endpoint fails

Turn the whole feature off with enabled: false: the init include emits nothing, the badge include renders nothing, and page-views.js is never requested.

The same is true of an absent page_views: block, which is what a consumer site inherits: remote_theme and the gem ship layouts, includes, and assets but not _config.yml, so a downstream site opts in by copying the block above into its own config. Nothing is emitted until it does.

Wiring the remote provider

Point endpoint at any service that answers with JSON containing a number:

page_views:
  provider  : 'remote'
  endpoint  : 'https://counter.example.com/views?site=zer0'   # or '…/views/{path}'
  method    : 'POST'
  count_key : 'views'
  • {path} in the URL is replaced with the URL-encoded page path. Without it, the path is appended as ?path=….
  • GET/HEAD methods send no body; anything else sends {"path": "/some/page/"} as JSON.
  • The response is read at count_key (views by default, dotted paths supported). Anything that isn't a finite number is treated as "no count", and the badge stays hidden.
  • Requests are credentials: "omit", so the endpoint needs permissive CORS.

Privacy

The privacy gates mirror the posthog: block, and they gate recording, not display β€” showing a number that is already known collects nothing:

Signal Effect
Do Not Track (respect_dnt) No view is recorded.
Global Privacy Control (respect_gpc) No view is recorded.
require_consent: true Nothing is recorded until the visitor accepts the analytics category in the cookie-consent banner. The pending view is recorded on the cookieConsentChanged event, so consenting mid-visit still counts the page being read.

With the remote provider, a blocked recording still performs a read-only GET so the public count renders. With local, there is nothing to read from anyone else, so the badge simply stays hidden.

dedupe: session (the default) counts one view per path per browser session, so a reload or a back-navigation does not inflate the number.


Placing the badge

The badge include is safe to use anywhere, any number of times β€” each instance resolves its own path:

{% include components/page-views.html %}                        <!-- current page -->
{% include components/page-views.html separator=true %}          <!-- meta-row form -->
{% include components/page-views.html url=post.url class="small" %}
{% include components/page-views.html icon="bi-eye-fill" %}
Param Default Purpose
url page.url Which page's count to show
class "" Extra classes on the wrapper
icon bi-eye Bootstrap icon
separator false Render a leading β€’ inside the badge, so it hides with it

The badge is server-rendered with the hidden attribute and is revealed only once a count exists. That is what keeps a page with no data from flashing "0 views", and why the separator lives inside the badge rather than beside it β€” a hidden badge leaves no dangling bullet in the meta row.

Paths are site-relative and baseurl-free (page.url); page-views.js normalizes location.pathname the same way, so a project-Pages site under a baseurl does not count /repo/foo/ separately from /foo/.


Runtime API

page-views.js exposes a small surface for other scripts (and for the Playwright spec):

window.zer0PageViews.get('/posts/some-post/'); // number | null
window.zer0PageViews.all();                    // { '/path/': count, … }
window.zer0PageViews.refresh();                // re-render every badge
window.zer0PageViews.reset();                  // clear the local store + session dedupe

Every resolved count also fires an event, so a site can react to it without polling:

document.addEventListener('zer0:page-views', (e) => {
  console.log(e.detail); // { path, count, provider }
});

Failure modes (all non-fatal by design)

Situation Behaviour
localStorage unavailable (private mode, blocked cookies) Storage access is probed and wrapped; the counter degrades to "no count" and the badge stays hidden.
Corrupt stored payload Discarded, store restarts empty.
Quota exceeded on write Write is dropped silently β€” the count is a nicety, never a hard failure.
Remote endpoint down / CORS-blocked / wrong shape Badge stays hidden (or falls back to the local counter with fallback_local: true); the reason goes to console.debug, not console.error, so it does not trip the console-error assertions in the test suite.
Store grows past max_entries Least-recently-viewed paths are evicted first.