cmskite
v0.1.3
Published
The official CMSKite client. Fetch your posts and measure who reads them, in a few lines.
Downloads
546
Maintainers
Readme
cmskite
The official CMSKite client. Fetch your posts and measure who reads them.
npm install cmskiteimport { 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
- Fetching posts
- Languages
- Pagination
- Filtering and sorting
- Categories, tags and authors
- Search
- Error handling
- Analytics
- Next.js
- React
- Node
- Security: which key goes where
- Versioning and compatibility
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 // numberFiltering 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 oneAnalytics
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
