npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

cmskite

v0.1.3

Published

The official CMSKite client. Fetch your posts and measure who reads them, in a few lines.

Downloads

546

Readme

cmskite

The official CMSKite client. Fetch your posts and measure who reads them.

npm install cmskite
import { createCMSKite } from 'cmskite'

const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })

const { items } = await cms.posts.list({ limit: 10 })

That is the whole of getting started. Everything below is detail you can reach for when you need it.


Contents


Initialization

import { createCMSKite } from 'cmskite'

const cms = createCMSKite({
  apiKey: process.env.CMSKITE_API_KEY!,

  // Everything below is optional.
  baseUrl: 'https://api.cmskite.com', // only for a self-hosted deployment
  timeoutMs: 10_000,                  // per request
  headers: { 'x-trace-id': traceId }, // sent on every request
  fetch: myFetch,                     // a test double, or fetch with retries
})

Create it once and reuse it. It holds no connection and no mutable state, so module scope is the right place for it.

Your API key is on Project → API keys in the dashboard. A CMSKite key is read-only and belongs to exactly one project; see Security before you put one in a browser bundle.


Fetching posts

// A page of posts.
const { items, pagination } = await cms.posts.list({ limit: 10 })

// One post by id.
const post = await cms.posts.get('post_01a0c...')

// One post by its address — what a /blog/[slug] page wants.
const post = await cms.posts.getBySlug('building-better-apps')

// The same, but null instead of a throw when there is no such post.
const post = await cms.posts.findBySlug(slug)
if (!post) return notFound()

// Posts like this one.
const { items } = await cms.posts.related(post.id, { limit: 3 })

A list omits post bodies — twenty full articles is a payload nobody asked for — and says so with bodyOmitted: true. Fetching one post always includes it.

Old addresses keep working

If a post has been renamed, its old slug still resolves and the answer carries slugRedirectedFrom. Use it to redirect rather than serving one post at two addresses:

const post = await cms.posts.getBySlug(params.slug)
if (post.slugRedirectedFrom) redirect(`/blog/${post.slug}`)

Languages

Posts can have translations. Ask for one with locale; without it you get the project's default language. A language with no version falls back to the default, and the post says so with fallback: true. Every post lists its other versions in translations.

const post = await cms.posts.findBySlug('namaste', { locale: 'hi' })
post?.translations // [{ locale: 'en', slug: 'hello', url: '/blog/hello' }, ...]

// Only posts that exist in Hindi, no fallback:
for await (const p of cms.posts.all({ locale: 'hi', fallback: false })) { … }

Single-post methods take locale in their options argument, the same place as signal. An unsupported locale is a 400 INVALID_REQUEST.


Pagination

CMSKite paginates by cursor, not by page number — ?page=5000 on a large table is a slow query anybody can issue. The cursor is opaque; pass it back and never parse it.

let cursor: string | undefined
do {
  const page = await cms.posts.list({ limit: 50, cursor })
  render(page.items)
  cursor = page.pagination.nextCursor ?? undefined
} while (cursor)

Or let the SDK write that loop for you. This is an async iterator, so a site with four thousand posts never holds four thousand posts in memory:

for await (const post of cms.posts.all({ status: 'published' })) {
  sitemap.add(`/blog/${post.slug}`)
}

pagination.total is only present when you ask for it, because counting is not free:

const page = await cms.posts.list({ withTotal: true })
page.pagination.total // number

Filtering and sorting

await cms.posts.list({
  limit: 20,
  status: 'published',
  category: 'engineering',        // slug or id
  tag: 'databases',               // slug or id
  author: 'author_01a0c...',
  search: 'postgres',
  sort: '-publishedAt',           // '-' is descending
  publishedAfter: '2026-01-01T00:00:00Z',
})

Omitted filters are left out of the request entirely, so you can pass an optional value straight through without guarding it first.


Categories, tags and authors

const { items: categories } = await cms.categories.list()
const { items: tags } = await cms.tags.list()
const { items: authors } = await cms.authors.list()

const category = await cms.categories.get('cat_01a0c...')

A category carries its full path — engineering/databases — so two subcategories both called "Guides" can be told apart.


Search

const { items } = await cms.posts.search('connection pooling', { limit: 10 })

Searches titles and bodies. Search is a paid capability; on a plan without it the call throws a CMSKiteError with status: 403.


Error handling

Everything that can go wrong arrives as one type, so you write one catch.

import { CMSKiteError } from 'cmskite'

try {
  const post = await cms.posts.getBySlug(slug)
} catch (error) {
  if (error instanceof CMSKiteError) {
    error.status      // 404, 401, 0 when nothing came back at all
    error.code        // 'NOT_FOUND', 'RATE_LIMITED', 'NETWORK', …
    error.requestId   // quote this when you ask us about it
    error.isNotFound  // convenience
    error.isAuthError
    error.isRetryable
  }
  throw error
}

A dropped connection, a timeout and a proxy answering with HTML are all CMSKiteError too — you will never see a bare TypeError: Failed to fetch from this package.

Retries. The client retries once, and only what a retry can fix: a dropped connection, a timeout, a 429, a 5xx. A 404 or a 401 is a settled answer, and asking twice only delays showing somebody the truth.

Cancellation. Every method takes a signal:

const controller = new AbortController()
const page = await cms.posts.list({ search }, { signal: controller.signal })
controller.abort() // e.g. a newer search superseded this one

Analytics

CMSKite counts a view when a page reports one — not when the API is asked for a post. Those are different numbers: a framework fetches every post at build time, a cached page is fetched once and read ten thousand times, and a crawler fetches each one exactly once.

So tracking lives in the browser:

import { createTracker } from 'cmskite/browser'

const analytics = createTracker({ apiKey: PUBLIC_KEY })

analytics.trackView(post.id)
analytics.trackClick(post.id, 'https://example.com/pricing')

It cannot break your site. Nothing here throws, nothing awaits a response, and a failed request is swallowed. Your article renders whether or not our analytics is having a good day.

It cannot slow your site. Events are queued and sent in one batch on a timer, using sendBeacon where it exists — which hands the batch to the browser and returns immediately, and still delivers after the page is closed.

It will not double-count. A view is remembered for the tab, so a refresh, a re-render or a client-side navigation back to a post already read reports nothing. The server deduplicates again by a deterministic id, because storage can be cleared and a second tab has its own.

Clicks are not deduplicated: pressing the same link twice is two clicks.

Turn it off where you want to:

createTracker({ apiKey: PUBLIC_KEY, enabled: process.env.NODE_ENV === 'production' })

Supply your own fetch to see the requests in a test:

const tracker = createTracker({ apiKey: KEY, fetch: myFetch })

A view is a browser, not a script

Requests that announce themselves as automated are accepted and not counted — curl, a crawler, a link-preview fetcher, a headless browser. That includes Node's own fetch, whose user agent is node, so calling trackView from a server records nothing. That is deliberate: a server fetching a post is not a reader, which is the whole reason it is not counted as one.

What is collected

No cookie, no browser storage identifier, no IP address and no user agent are stored. A visitor is a salted hash that cannot be recomputed once the day's salt rotates at midnight — including by us. The path is stored without its query string, and a referrer is reduced to its host.

There is no identifier, so there is nothing for a visitor to consent to.


Next.js

Fetch on the server, track in the browser. That split is the whole integration.

app/blog/[slug]/page.tsx

import { notFound } from 'next/navigation'
import { createCMSKite } from 'cmskite'
import { TrackView } from './track-view'

const cms = createCMSKite({ apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })

export async function generateStaticParams() {
  const params = []
  for await (const post of cms.posts.all({ status: 'published' })) {
    params.push({ slug: post.slug })
  }
  return params
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await cms.posts.findBySlug(slug)
  if (!post) notFound()

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

app/blog/[slug]/track-view.tsx

'use client'

import { useTrackView } from 'cmskite/react'

export function TrackView({ postId }: { postId: string }) {
  // A project key (csk_live_…), baked in at build time. Off until it is set, and off in dev.
  useTrackView(postId, {
    apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY ?? '',
    enabled: Boolean(process.env.NEXT_PUBLIC_CMSKITE_KEY) && process.env.NODE_ENV === 'production',
  })
  return null
}

One key, one .env entry. The server code and the tracker both read it — see Security.

NEXT_PUBLIC_CMSKITE_KEY=csk_live_…

NEXT_PUBLIC_ values are compiled in when the site is built. After you set or change it on your host, redeploy.

Revalidation. The SDK uses fetch, so Next's cache options work. Pass them through your own wrapper if you want ISR:

const cms = createCMSKite({
  apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY!,
  fetch: (url, init) => fetch(url, { ...init, next: { revalidate: 300 } }),
})

React

For a client-side app, fetch with whatever you already use for server state — TanStack Query, SWR, your own hook. The SDK deliberately ships no data hook: a usePosts here would be a worse query library wearing our name.

What it does ship is view tracking, because getting that right means knowing when a component mounted, surviving React's double-invoked effects in development, and not re-reporting on every render.

'use client'

import { useCMSKiteAnalytics, useTrackView } from 'cmskite/react'

export function Article({ post }) {
  useTrackView(post.id, { apiKey: PUBLIC_KEY })
  const analytics = useCMSKiteAnalytics({ apiKey: PUBLIC_KEY })

  return (
    <article>
      <h1>{post.title}</h1>
      <a href={cta} onClick={() => analytics.trackClick(post.id, cta)}>Read more</a>
    </article>
  )
}

Passing a new postId reports the new post, which is what makes client-side navigation between two articles count as two views with no router integration.


Node

import { createCMSKite } from 'cmskite'

const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })

const { items } = await cms.posts.list({ limit: 100 })

Node 18 or newer, for built-in fetch and AbortSignal.any. ESM and CommonJS both work:

const { createCMSKite } = require('cmskite')

Security: which key goes where

CMSKite has one kind of key and it is read-only. The only scope a key can hold is blog:read: it returns published content and can write, change or delete nothing. There is no secret credential in this SDK, because there is none to have.

That makes a key safe in a browser bundle, with one thing to do first:

Restrict the origins. Project → Settings → Allowed origins. List the sites that may use the key. Until you do, any page anywhere can read your published content with it — which is content you are already publishing, but it is still your bandwidth.

One key, one variable. The same project key fetches posts and reports views, so a site needs only one:

| Your site | Variable | Used by | |---|---|---| | Next.js | NEXT_PUBLIC_CMSKITE_KEY | createCMSKite on the server, and the tracker | | React with Vite | VITE_CMSKITE_KEY | createCMSKite and the tracker | | Node, no browser code | CMSKITE_API_KEY | createCMSKite |

Never put a dashboard session token or an agent token in a browser. Those act for a person; an API key acts for a project.


Versioning and compatibility

The package follows semantic versioning, and the guarantee is about what you import rather than about what we run.

  • Patch — fixes. Nothing you wrote changes.
  • Minor — new methods, new optional fields on a response. Existing code keeps working, and a field you already read keeps meaning what it meant.
  • Major — something you import changed shape. Rare, and it will come with a migration note.

The API is versioned separately at /v1. The SDK pins to it, so an API change reaches you as an SDK release you choose to take rather than as your site breaking on a Tuesday.

Anything not exported from cmskite, cmskite/browser or cmskite/react is internal, including the transport and the response envelope. Those are the parts we expect to change.

Response types describe the API, not the database. A column renamed behind the scenes is not your problem.


License

MIT