@conapps/cms-sdk
v0.3.2
Published
TypeScript SDK for the platform Content Delivery API. Works in browsers, Node, Nuxt and Vue.
Maintainers
Readme
@conapps/cms-sdk
Typed client for the platform's Content Delivery API — the read-only side of the CMS, the one your website or app talks to. Runs in Node 18+, Nuxt (SSR + client), Vue and the browser.
npm install @conapps/cms-sdkBefore you write any code: three values
Every call needs these, and every one of them comes from the platform UI. Getting them is the whole setup — there is no key file to download and no dashboard to generate a long-lived token in.
| What | Where it comes from | Looks like |
|---|---|---|
| Endpoint | the CMS delivery host (or the gateway, see below) | https://cms.conapps.net |
| Workspace id | the address bar of your workspace in the backoffice | ws_4a7d… |
| Client id + secret | Settings → API integrations → New integration, scope content-management:read | cms-prod / shown once |
Two endpoints work, and the bare gateway host is not one of them.
|
endpoint| Result | |---|---| |https://cms.conapps.net| ✅ the delivery host — a CDN can sit in front of it, no gateway hop | |https://api.conapps.net/api/v1/workspaces/<ws>/services/content-management| ✅ through the gateway, same responses | |https://api.conapps.net| ❌ 404 on every call |Prefer the delivery host: it is what the CDN caching is built around. The gateway route is there when your network only allows one platform host, and it needs the full service prefix — the SDK appends
/delivery/v1/spaces/…to whatever you give it, so the workspace id legitimately appears twice.The third row is the trap, and it is silent: the gateway answers 404 for a path it does not route, which reads exactly like content that does not exist.
There are no CMS "delivery tokens" to create. Earlier versions of this document sent people looking for Settings → Content → Delivery Tokens; that screen does not exist and never did. Machine access on this platform is always an API integration exchanged for a short-lived token — see below.
Quick start
import { createCmsClient } from '@conapps/cms-sdk';
const cms = createCmsClient({
endpoint: 'https://cms.conapps.net',
workspaceId: 'ws_4a7d',
auth: {
issuer: 'https://auth.conapps.net/realms/platform',
clientId: process.env.CMS_CLIENT_ID!,
clientSecret: process.env.CMS_CLIENT_SECRET!,
},
defaultLocale: 'de-DE',
});
const posts = await cms.getEntries({ contentType: 'blogPost', take: 10 });
const post = await cms.getEntry('abc123', { include: 2 });
const found = await cms.getEntryBy('blogPost', 'slug', 'hello-world');That is the complete setup. The SDK fetches the token, caches it, refreshes it before it expires and retries once if the server rejects it anyway.
Authentication, and the mistake everybody makes once
An access token from this platform lives about five minutes. Handing the SDK a token string therefore works exactly as long as your first test run and then fails forever, with a 401 that looks like a wrong secret:
// ✗ Works for five minutes. Then 401, in production, at 3am.
createCmsClient({ …, token: process.env.CMS_TOKEN! })Three supported shapes, in the order you should reach for them:
// ✓ 1. Let the SDK own the token. Server-side.
createCmsClient({ …, auth: { issuer, clientId, clientSecret } })
// ✓ 2. Your own provider, if the token comes from somewhere else
// (a secrets broker, another service, a test double).
createCmsClient({ …, token: async () => myBroker.currentCmsToken() })
// ✓ 3. A fixed string — only for a script that finishes in minutes.
createCmsClient({ …, token: 'eyJhbGciOi…' })clientSecret is a password for the workspace. Never ship it to a browser.
The browser calls your backend, your backend calls this SDK. The Nuxt example
below is built that way.
You can also build the provider yourself and share one token across several clients — it is exported from this package, so there is no second package to install:
import { createTokenProvider, createCmsClient } from '@conapps/cms-sdk';
const token = createTokenProvider({ issuer, clientId, clientSecret });
const master = createCmsClient({ endpoint, workspaceId, token, environment: 'master' });
const staging = createCmsClient({ endpoint, workspaceId, token, environment: 'staging' });When a call is rejected
CmsAuthError carries a reason that says which of the usual four causes it is,
read out of the token that was just sent:
CmsAuthError: Delivery API rejected the request (403): the token is issued for
workspace ws_other, but the request addresses ws_4a7d — an API integration
belongs to exactly one workspaceFailures from the token endpoint are a PlatformAuthError and are translated the
same way — invalid_client becomes a sentence about a rotated secret rather than
an OAuth error code.
API
createCmsClient(opts) / new CmsClient(opts)
| Option | Type | Description |
|---|---|---|
| endpoint | string | Delivery host, e.g. https://cms.conapps.net. Required. |
| workspaceId | string | Workspace ("space") id. Required. |
| auth | ClientCredentialsOptions | Client credentials; the SDK manages the token. Pass this or token. |
| token | string \| () => string \| Promise<string> | A token, or a function returning one. |
| environment | string | Default "master". |
| defaultLocale | string | Used when a call omits locale. |
| assetBaseUrl | string | If set, Asset.url becomes ${assetBaseUrl}/${storageKey}. |
| fetch | typeof fetch | Custom fetch — used for content and for the token request. |
| cacheTtlMs | number | In-memory GET cache TTL. Default 0 (off). |
Methods
client.getContentTypes(): Promise<Collection<ContentType>>; // apiIdentifier is what `contentType` wants
client.getEntries<TFields>(query?): Promise<Collection<Entry<TFields>>>;
client.getEntry<TFields>(id, query?): Promise<Entry<TFields> | null>;
client.getEntryBy<TFields>(contentType, field, value, query?): Promise<Entry<TFields> | null>;
client.getAsset(id): Promise<Asset | null>;
client.withPreview(context): CmsClient;
client.clearCache(): void;include: number resolves linked entries and assets up to that depth — the SDK
fetches each id and inlines the resolved object back into the field.
getEntryBy is a scan, and says so
The Delivery API has no server-side filter on arbitrary fields. getEntryBy
therefore pages through the content type and compares locally:
await cms.getEntryBy('blogPost', 'slug', 'hello-world') // walks all pages
await cms.getEntryBy('blogPost', 'slug', 'x', { maxScan: 1000 }) // throws past 1000Cheap for a few hundred entries, a real cost for thousands. On a hot path, keep
your own slug → id map and use getEntry.
Until
0.3.0this helper read a single page of 100 and returnednullfor everything after it. A site launched fine and began 404ing its older posts months later, with nothing in any log. It now walks to the end, and throws atmaxScanrather than answeringnullto a question it did not finish asking.
Typed fields
interface BlogPost {
title: string;
slug: string;
body: string;
coverImage?: { id: string; url?: string; fileName: string };
}
const post = await cms.getEntry<BlogPost>('abc123', { include: 1 });
post?.fields.title; // stringErrors
import {
CmsError, CmsAuthError, CmsNotFoundError, CmsScanLimitError, PlatformAuthError,
} from '@conapps/cms-sdk';Everything this SDK throws extends CmsError. One catch on the base class
is complete.
| Class | When |
|---|---|
| CmsError | base class, and any other non-2xx — .status, .url, .body |
| CmsConnectionError | the request never arrived (DNS, refused, timeout, TLS) — .code, .endpoint |
| CmsTokenError | the token endpoint refused the credentials — .tokenUrl |
| CmsAuthError | 401/403 — .reason says which cause it is |
| CmsNotFoundError | 404 |
| CmsScanLimitError | getEntryBy reached maxScan — .scanned, .maxScan |
status is the HTTP status — 0 when there was no response: the request
never left (scan limit) or never arrived (connection, token endpoint).
PlatformAuthError stays exported for callers who use createTokenProvider
directly; when the client fetches the token, it arrives wrapped as
CmsTokenError.
Preview
withPreview(context) returns a second client that reads the same space with
an editor's context — the draft revision, a chosen instant, a chosen audience.
Preview tickets are verified with
@conapps/cms-preview; this
package only carries the resulting context into the query.
const ticket = await previewHandler.read(cookieHeader) // @conapps/cms-preview
const client = ticket ? cms.withPreview(ticket.context) : cms
const posts = await client.getEntries({ contentType: 'blogPost' })A separate instance, not a flag: the public client keeps its cache and its
published view, so a preview can never write a draft into an entry the public path
reads back. The preview client cannot cache — cacheTtlMs is forced to 0,
because the cache key is the URL and two contexts differing only in a forgotten
parameter would share an entry.
| Context field | Effect |
|---|---|
| state: 'draft' (default) | ?preview=true — drafts, and the current draft of a changed entry |
| state: 'published' | no preview parameter; what the public sees |
| at | ISO instant the visibility window is evaluated against |
| audience | audiences of the simulated visitor |
| sourceMap | ask for the field-origin map that powers click-to-edit |
?preview=trueneeds a token carryingcontent-management:preview. Put that scope on a separate API integration, never on the public one: with it on the public client one bug makes drafts public, and without it the Delivery API silently ignores the parameter.
audience: []and an absentaudienceare different requests — the first simulates a visitor nothing is known about (restricted content is withheld, as in public), the second does not narrow by audience at all.
Vue / Nuxt
// main.ts (Vue)
import { createCmsClient } from '@conapps/cms-sdk';
import { cmsPlugin } from '@conapps/cms-sdk/vue';
app.use(cmsPlugin, createCmsClient({ endpoint, workspaceId, token }));<script setup lang="ts">
import { useEntries } from '@conapps/cms-sdk/vue';
const { data, pending, error } = useEntries({ contentType: 'blogPost', take: 5 });
</script>Nuxt: the client stays on the server
// server/utils/cms.ts — one client, one token, all server routes
import { createCmsClient, type CmsClient } from '@conapps/cms-sdk';
let cms: CmsClient | undefined;
export function getCms(): CmsClient {
cms ??= createCmsClient({
endpoint: process.env.CMS_ENDPOINT!, // https://cms.conapps.net
workspaceId: process.env.CMS_WORKSPACE_ID!,
auth: {
issuer: process.env.KEYCLOAK_ISSUER!, // https://auth.conapps.net/realms/platform
clientId: process.env.CMS_CLIENT_ID!,
clientSecret: process.env.CMS_CLIENT_SECRET!,
},
environment: process.env.CMS_ENVIRONMENT ?? 'master',
cacheTtlMs: 15_000,
fetch: $fetch as unknown as typeof fetch,
});
return cms;
}// server/api/posts.get.ts — the browser never sees the secret
export default defineEventHandler(async () => {
const { items } = await getCms().getEntries({ contentType: 'blogPost' });
return items;
});Put CMS_CLIENT_SECRET in the server runtime config only. Anything under
runtimeConfig.public is shipped to the browser.
A complete working example lives under
../examples/nuxt-demo.
Troubleshooting
| Symptom | Cause |
|---|---|
| Every call 404s | endpoint is the bare gateway host. Use the cms. host, or the gateway with the full /api/v1/workspaces/…/services/content-management prefix. |
| CmsConnectionError … does not resolve | The host name is wrong, or its DNS record is missing. The error names the host. |
| Works locally, 401 in production after a few minutes | A token string instead of auth / a provider function. |
| 403 mentioning another workspace | The integration belongs to a different workspace than workspaceId. |
| invalid_client from the token endpoint | Secret rotated, integration revoked, or the env var is empty. |
| preview=true returns published content | The token lacks content-management:preview; the parameter is ignored, not refused. |
| getEntryBy returns null for old entries | Fixed in 0.3.0 — upgrade. |
| Linked entries come back as { sys: { type: 'Link' } } | Pass include: 1 (or more). |
| getEntries({ contentType }) returns entries of other types | A service older than 2026-09-18 silently ignored an unknown content_type and answered with everything. Check the identifier against getContentTypes()[].apiIdentifier. |
| You cannot find the value for contentType | It is apiIdentifier on a content type — not its sys.id, which is the row id. On an entry it appears as contentType.sys.id. |
Build & test
npm install
npm run build # tsup → dist/{index,vue}.{js,cjs,d.ts}
npm test # vitest — 92 unit tests
npx vitest run --coverage # ≥ 98% line coverage on src/The suite covers the HTTP client (URL composition, locale defaulting, paging, encoding, all error mappings, in-memory cache TTL + invalidation, asset URL decoration, single-level + array + recursive link resolution, broken-link tolerance), the token provider (caching, skew, single-flight, invalidation, every OAuth rejection, blank-credential guards), the 401 refresh-and-retry path, and every Vue composable.
