Measuring

Why your views are zero

If every post reads 0 views, almost nothing is broken. A CMSKite integration is two halves — your server fetches the content, and the reader’s browser reports the view — and it is entirely possible to build the first half and never notice the second is missing. The site works. Nothing errors. Nobody is counted.

Updated September 24, 2026

6 min read

The usual cause, in one paragraph

Fetching a post is not reading a post, so we never count it as one. A build fetches all 200 of your posts on deploy; a cached page is fetched once and read ten thousand times; a crawler fetches every post exactly once. Counting requests would be wrong in all four directions, so a view is reported by the page a person actually opened, and by nothing else.

That report is a separate snippet from the code that fetches your content. If it was never added, every number stays at zero forever and there is no error anywhere to find.

The tracker needs the post id, not the slug. Send post.id (it looks like post_01h…). An id we do not recognise is dropped rather than refused — deliberately, so nobody can use this endpoint to discover which posts exist — which means a slug here produces a page that looks completely healthy and counts nothing.

Check it in ten seconds

Open your project in the dashboard. If anything is missing, the top of the project page says so and names the one thing to fix. It checks whether the project has a key, whether any site has ever used it, and whether views are arriving.

If you are working with an AI assistant through the CMSKite MCP server, ask it to run check_integration — it reads the same checks and will tell you which half is missing.

  • No API key — the site needs a project key (csk_live_…), and so does the tracker. If the site reads content with any other credential instead, replace it: see the warning below.
  • The key has never been used — the site is not fetching your content. Check the key is in the right environment variable.
  • No views are being reported — this page is the one you want. The content half works; the tracking half is missing.

React and Next.js

Use a project key, never a session or an AI tool’s credential. The browser needs a project key: it starts csk_live_, is read-only, and returns published content only, so it is safe in a page. A dashboard session can write and delete, and the credential an AI tool gets when you connect it can write (and delete, if you allowed it) — putting one in NEXT_PUBLIC_CMSKITE_KEY publishes those powers to every reader. Create project keys under Project → API keys.
bash
npm install cmskite

components/track-view.tsx

tsx
'use client'

import { useTrackView } from 'cmskite/react'

// A project key (csk_live_…). Baked in at build time: after setting it, redeploy.
const KEY = process.env.NEXT_PUBLIC_CMSKITE_KEY

/**
 * Reports one view for this post, once. Safe to render on every navigation:
 * the hook reports the first mount only, and nothing here can throw.
 */
export function TrackView({ postId }: { postId: string }) {
  useTrackView(postId, {
    apiKey: KEY ?? '',
    // Off until the key exists, and off in `next dev` so your own reloads are not counted.
    enabled: Boolean(KEY) && process.env.NODE_ENV === 'production',
  })
  return null
}

Then render it inside the page that shows one post, with that post’s id. The key must be the NEXT_PUBLIC_ one — this runs in the browser, and a key without the prefix is undefined there.

NEXT_PUBLIC_ values are fixed when the site is built. Next.js (and Vite, for VITE_ variables) copies these values into the JavaScript bundle at build time. Adding the variable on Vercel or any other host changes nothing until you redeploy. Until then the key is undefined, and the enabled guard above keeps the tracker switched off rather than sending requests that fail.

app/blog/[slug]/page.tsx

tsx
const post = await cms.posts.findBySlug(slug)
if (!post) notFound()
// The body is stored as written: turn markdown into HTML (npm install marked).
const html = post.bodyFormat === 'markdown' ? await marked.parse(post.body) : post.body

return (
  <article>
    <h1>{post.title}</h1>
    <div dangerouslySetInnerHTML={{ __html: html }} />
    <TrackView postId={post.id} />
  </article>
)

Plain HTML, PHP, WordPress, or anything else

No package needed. This is one HTTP request from the reader’s browser. Paste it at the end of the page that shows a single post, and fill in the two values at the top.

The whole tracker

html
<script>
(function () {
  var POST_ID = 'PASTE_THE_POST_ID'      // the post's id, like post_01h… NOT the slug
  var KEY     = 'PASTE_THE_PROJECT_KEY'  // your project's read-only key
  if (!POST_ID || !KEY) return

  // One view per post per tab. A refresh is not a second reader.
  try {
    var seen = 'cmskite:v:' + POST_ID
    if (sessionStorage.getItem(seen)) return
    sessionStorage.setItem(seen, '1')
  } catch (e) {
    // Private mode. Count the view rather than lose it.
  }

  var url = 'https://api.cmskite.com/v1/blog/events?key=' + encodeURIComponent(KEY)
  var body = JSON.stringify({
    events: [{ type: 'view', postId: POST_ID, path: location.pathname, referrer: document.referrer }]
  })

  // text/plain is deliberate — see the note below.
  var type = 'text/plain;charset=UTF-8'
  try {
    if (navigator.sendBeacon && navigator.sendBeacon(url, new Blob([body], { type: type }))) return
  } catch (e) {}
  try {
    fetch(url, { method: 'POST', headers: { 'content-type': type }, body: body, keepalive: true })
      .catch(function () {})
  } catch (e) {}
})()
</script>
Why text/plain, when the body is JSON. application/json is not on the browser’s CORS safelist, so a browser sends a preflight request first — and a preflight carries no key, so we cannot tell which project’s settings to answer from and have to refuse it. The view is then dropped with no error visible anywhere. text/plain is safelisted, so the same bytes go straight out. We accept both; only one of them arrives.

referrer is the page the reader came from (document.referrer), which the post’s Referrers panel shows. Leave it out and the view still counts, as direct. A referrer on your own site is counted as direct too.

In PHP or a template, print the post id into the snippet rather than hard-coding it:

PHP

php
<script>
  var POST_ID = <?= json_encode($post['id']) ?>
  var KEY     = <?= json_encode(getenv('CMSKITE_PUBLIC_KEY')) ?>
  // …the rest of the snippet above, unchanged
</script>

Counting clicks too

Same endpoint, type "click", plus a nonce. The nonce is what tells us two presses of the same link are two clicks rather than one reported twice — leave it out and they collapse into one.

js
function trackClick(postId, label) {
  var url = 'https://api.cmskite.com/v1/blog/events?key=' + encodeURIComponent(KEY)
  var body = JSON.stringify({
    events: [{
      type: 'click',
      postId: postId,
      target: label,
      nonce: String(Math.random()).slice(2),
    }]
  })
  navigator.sendBeacon(url, new Blob([body], { type: 'text/plain;charset=UTF-8' }))
}

Consent and Do Not Track

The tracker sets no cookies. It keeps one sessionStorage entry per post for the open tab, so a view is not sent twice, and it sends the post id and the page path. The server does not store the IP address: it keeps, for 90 days, a hash that changes every day, so the same reader cannot be followed from one day to the next.

The tracker does not check Do Not Track or your consent banner by itself. If your site asks for consent first, pass enabled: false until the reader agrees. When enabled is false, the tracker queues nothing and sends nothing. Whether you need consent for this is for you and your lawyer to decide. We have kept the data small to make that decision easier.

Still zero after all that

  • Open a post in your browser, then open the Network tab and reload. You should see a request to api.cmskite.com/v1/blog/events. If there is none, the snippet is not running — check it is on the single-post page and not only on the index.
  • The snippet is on the page and there is still no request: the key was probably undefined when the site was built. NEXT_PUBLIC_ and VITE_ variables are baked in at build time, so set the variable and then redeploy. In the Next.js snippet above, the tracker is also off in next dev by design.
  • If the request is there and red, read the status. 401 means the key is wrong or missing. 403 means this domain is not in the project’s allowed origins.
  • If the request is there and returns 202, the view was accepted. One view from one browser is enough: it shows within about a minute. A reader counts once per post per day, so reloading the same post does not add more.
  • Test in an ordinary browser. Headless browsers (Playwright, Puppeteer), curl and scripts get the same 202 but are not counted, so an automated check cannot prove the tracker works.
  • Check you sent post.id and not post.slug. This is the commonest one, and it looks identical to everything working.
  • Ad blockers and privacy extensions block analytics requests, including this one. Your own visits may not be counted.