@conapps/cms-sdk

TypeScript SDK for the platform Content Delivery API. Works in browsers, Node, Nuxt and Vue.
709
Install
npm install @conapps/cms-sdk
Latest Version:0.3.2
Vue:3
License:MIT
Last Updated:Sep 18, 2026
Links: npm
Maintainer: fncmedia

@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-sdk

Before 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 workspace

Failures 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 1000

Cheap 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.0 this helper read a single page of 100 and returned null for 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 at maxScan rather than answering null to 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; // string

Errors

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=true needs a token carrying content-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 absent audience are 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.

Related Packages

nuxt-microcms-module

microCMS integration for Nuxt.js

23,457 29
@eaera-com/cms-sdk

EAERA CMS SDK — data access layer for Nuxt 3 apps (ofetch + Pinia + typed servic...

7,106
atpares

Blog for atpares sites: ready pages, SEO, sitemap and content API client for Nux...

886
@flexacms/nuxt

Nuxt module for the Flexa headless CMS: auto-imported composables and a read-onl...

370 0
@eldrajs/theme-nuxt

Nuxt module for Eldra themes: wires @eldrajs/vite-plugin-theme, the gateway clie...

0 0

Version History

Version Released Vue License
0.3.2 ^3.3.0 MIT
0.3.1 ^3.3.0 MIT
0.3.0 ^3.3.0 MIT
0.1.0 ^3.3.0 MIT