Start here

JavaScript SDK

Everything the API does is reachable with `fetch` and a bearer token, and that is still true. The SDK is there because four things are tedious to write again on every site: cursor pagination, slug redirects, typed responses, and a view counter that never breaks the page.

Updated 24 September 2026

4 min read

Install

bash
npm install cmskite

No dependencies. It ships ESM and CommonJS side by side with its own type declarations, so it works in Next.js, Vite, plain Node and a require call without a build step or an @types package.

ts
import { createCMSKite } from 'cmskite'

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

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

Three entry points, on purpose

The content client runs anywhere. The tracker needs a browser. Keeping them apart means a server bundle never pulls in code that reaches for document, and a Node script never ships a view counter it cannot use.

ImportWhat it holdsRuns where
cmskiteThe content client, errors, and every typeAnywhere
cmskite/browserThe analytics trackerBrowser
cmskite/reactuseTrackView, useCMSKiteAnalyticsReact, client components

Reading content

ts
// 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. cms.categories, cms.tags and cms.authors have the same shape.

Renaming a post does not break its links. An old slug still resolves, and the answer carries slugRedirectedFrom. Check for it and redirect, or you serve one post at two addresses and split its search ranking between them.
ts
const post = await cms.posts.getBySlug(params.slug)
if (post.slugRedirectedFrom) redirect(`/blog/${post.slug}`)

Pagination without the loop

CMSKite paginates by cursor, not by page number. The cursor is opaque: pass it back, never parse it. For walking everything — a sitemap, a static build — all() is an async iterator, so a site with four thousand posts never holds four thousand posts in memory.

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

Errors

Every failure — a 404, a 429, a timeout, a DNS error — arrives as one CMSKiteError carrying the HTTP status, the API error code and the requestId to quote in a support ticket. One catch, not two.

ts
import { CMSKiteError, ErrorCode } from 'cmskite'

try {
  const post = await cms.posts.getBySlug(slug)
} catch (error) {
  if (error instanceof CMSKiteError) {
    if (error.code === ErrorCode.NotFound) return notFound()
    console.error(error.status, error.code, error.requestId)
  }
  throw error
}

A request that failed for a reason worth retrying — a timeout, a 5xx, a 429 — is retried once automatically. A 404 or a bad key is not: retrying a definite answer only makes it slower.

Counting readers

One hook, in a client component. It reports the view when the post renders, and it is safe in an effect that runs on every render — the same post in the same tab is reported once.

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

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
}

The tracker cannot throw, cannot block a render, and cannot double-count. Events are queued and flushed on a timer with sendBeacon, which still delivers after the reader has closed the tab. Nothing awaits a response, because there is nothing in the response.

No cookie, no consent banner. A reader is identified by a hash of a daily-rotating salt, the project, the IP and the user agent. The salt expires after 48 hours, after which a day’s hashes cannot be recomputed even by us; the hash itself is kept with each event for 90 days. Nothing is stored in the browser except a note of which posts this tab already reported. See Analytics for the whole design.

Which key goes where

One project key, in one variable. In Next.js that is NEXT_PUBLIC_CMSKITE_KEY, and both halves read it: the server code that fetches posts, and the tracker in the browser. A server-only app with no browser code (a Node script, PHP, Python) calls the same key CMSKITE_API_KEY.

Your siteEnvironment variableUsed by
Next.jsNEXT_PUBLIC_CMSKITE_KEYFetching posts on the server, and the view tracker
React with ViteVITE_CMSKITE_KEYFetching posts, and the view tracker
Node, PHP, Python, WordPressCMSKITE_API_KEYFetching posts

A key in a browser bundle is readable by anybody who opens the network tab, so a CMSKite key can only read, only one project, and only from the origins you list. The worst somebody can do with a stolen one is read posts you already publish. Restrict allowed origins on the key before you ship it.

Never put a session or an AI tool’s credential in the SDK. The SDK takes an API key — csk_live_…. A dashboard session can write and delete, and a connected AI tool can write (and delete, if you allowed it). Neither belongs in a website’s code.

Versioning

Semver, strictly. A patch fixes a bug, a minor adds something you can ignore, and a major is the only kind that can require a change from you. Anything not exported from cmskite, cmskite/browser or cmskite/react is internal and can change in a patch — so do not import from a deep path.

The SDK talks to /v1 and will keep doing so. A v2 API would be a new major of the SDK, not a silent switch underneath a running site.