SDK

JavaScript SDK

@obelism/content: ContentClient for the delivery API, preview, the editor bridge and prompt box, editing on the site, webhooks, rich text and image helpers, and the improve block.

11 min read
npm i @obelism/content

@obelism/content runs anywhere with fetch: Node, Bun, Deno, Workers and the browser. Its source is packages/content in Obelism/sdk.

ImportHas
@obelism/contentContentClient, readPreviewParams, editable, rich text, images, types
@obelism/content/bridgegetContentBridge, mountPromptBox
@obelism/content/sitecreateSiteEditing, readSiteToken
@obelism/content/promptcreatePromptHandler, verifyPromptRequest
@obelism/content/webhookscreateWebhookHandler, verifyWebhook, the WebhookEvent types (0.6.0 and later)
npx @obelism/contentthe CLI

ContentClient

Reads one space environment through the delivery API. The token decides the space, the environment and whether drafts are visible: a delivery token (cms_d_…) sees published content, a preview token (cms_p_…) drafts too. Keep preview tokens server-side.

import { ContentClient } from '@obelism/content'

const content = new ContentClient({
	token: process.env.CONTENT_DELIVERY_TOKEN!,
	// url: 'https://content.obelism.studio',
	// locale: 'nl', depth: 1, richText: 'json', timeout: 10_000,
	// init: { next: { revalidate: 60 } },   // passed to every fetch
})

await content.getPage('blog/hello') //  Entry | null
await content.findPage('blog/old') //  { entry } | { redirect: { path } } | null
await content.getAllRedirects() //  every redirect, per locale (async iterator)
await content.getEntry('entry_…') //  Entry | null
await content.getEntries({ contentType: 'post', limit: 20, skip: 0 }) //  { items, total, limit, skip }
for await (const post of content.getAllEntries({ contentType: 'post' })) {
	// every entry, fetched 100 at a time
}
await content.getFirstEntry({
	contentType: 'navigation',
	fields: { key: 'main' },
}) //  Entry | null
await content.getContentModel() //  the model file
OptionDefault
tokenA delivery, preview or site token, or a token source
spaceYour space: a token for another one is refused
environmentYour environment: a token for another one is refused
draftsfalseAsk for drafts, with a host token
urlhttps://content.obelism.studio
localethe space's defaultA locale code, or * for every locale
depth1Levels of references to inline, 0–5
richText'json''html' for rendered HTML strings
timeout10000Milliseconds before a request is aborted
initPassed to every fetch
fetchglobal fetchYour own implementation

Each method takes { locale, depth, richText, drafts, init } to override the defaults; getEntries and getAllEntries also contentType, parentPath, fields, order, limit (1–100) and skip. Missing pages and entries are null; any other failure throws a ContentApiError with status and the API's message.

Without a stored token

On Vercel, or in GitHub Actions, pass a token source instead of a token: the client sends the identity token the host gives the deployment, so there is nothing to store or rotate. Connect the deployment first, under Connected deployments on the space's page on www.obelism.studio (how).

import { ContentClient } from '@obelism/content'
import { vercelOidc } from '@obelism/content/oidc' // needs @vercel/oidc installed

const content = new ContentClient({
	token: vercelOidc(), // or githubOidc() in a workflow with `permissions: id-token: write`
	space: process.env.CONTENT_SPACE_ID,
	environment: process.env.CONTENT_ENVIRONMENT,
})

const draft = await content.getPage(path, { drafts: true }) // a preview route

A host token reads published content unless the client or the request sets drafts: true, which needs a rule that reads drafts. token takes any function that gives a token per request.

Locally, vercelOidc() gets a token after vercel link and vercel login, for the connection's development rule: see local development.

Paths per locale

Since @obelism/content 0.8.0. A page or folder can have a slug per locale (see Locales). getPage(path, { locale }) looks the path up in that locale's paths, entry.path is the path in that locale and entry.paths the path in every locale, for hreflang links and language switchers:

import { alternateLinks } from '@obelism/content'

const page = await content.getPage(slug.join('/'), { locale })
// [{ hreflang: 'en', href: '…' }, { hreflang: 'nl', href: '…' }, { hreflang: 'x-default', href: '…' }]
const alternates = page
	? alternateLinks(page, {
			url: (path, code) => `https://example.com/${code}/${path}`,
			defaultLocale: 'en',
		})
	: []

url turns a path and a locale into an absolute URL, however your site lays them out; defaultLocale adds an x-default link (0.9.0 and later).

The locale is never part of a path; your routes decide where it goes. A page that moved keeps its old path as a redirect in each locale. findPage answers it, so the route can send a 301, and getRedirects({ locale, limit, skip }) / getAllRedirects({ locale }) list them ({ locale, from, to }) for redirect rules built ahead of time:

const found = await content.findPage(path, { locale })
if (found?.redirect)
	return Response.redirect(`${origin}/${locale}/${found.redirect.path}`, 301)
if (!found) return notFound()

Sitemaps

sitemapXml(entries, { url, defaultLocale, exclude? }) builds a sitemap.xml: one <url> per page and locale, each listing the page in every locale (xhtml:link, the hreflang alternates Google reads), with lastmod from updatedAt. Data entries (no paths) are skipped, and so are pages with the SEO template's Hide from search engines (noIndex) on, unless you pass your own exclude. Fetch the pages with a delivery token, so drafts stay out:

import { sitemapXml } from '@obelism/content'

export const GET = async () => {
	const pages = []
	for await (const page of content.getAllEntries({ contentType: 'page' }))
		pages.push(page)
	return new Response(
		sitemapXml(pages, {
			url: (path, locale) => `https://example.com/${locale}/${path}`,
			defaultLocale: 'en',
		}),
		{ headers: { 'Content-Type': 'application/xml' } },
	)
}

A link field arrives as Link: the page it goes to (entry: id, title, slug, path) or its url, plus query and hash, or null when the page isn't published. Where a page's path lives on your site is your routing, so linkHref(link, { url }) takes it as url (default /<path>) and adds the query and hash (0.17.0 and later):

import { linkHref } from '@obelism/content'

;<a
	href={
		linkHref(block.link, { url: (path) => `/${locale}/${path}` }) ?? undefined
	}
>
	{block.label}
</a>

A link to a Shopify product is type: "product", with the product ({ id, handle }) and its product page as entry (or null); linkHref puts it at your product route, product (default /products/<handle>; 0.18.0 and later):

linkHref(block.link, {
	product: ({ handle }, page) => `/${locale}/products/${handle}`,
})

Filtering and ordering

fields filters on contentType's fields, keyed by apiId with an optional operator; order sorts. They are the delivery API's filters and order:

// The 10 newest news posts from this year
await content.getEntries({
	contentType: 'post',
	fields: { category: 'news', 'date[gte]': '2026-01-01' },
	order: '-fields.date',
	limit: 10,
})

// A list means one of them (`[in]`)
await content.getEntries({
	contentType: 'post',
	fields: { tags: ['design', 'code'] },
})

Operators: ne, in, gt, gte, lt, lte (number and date fields) and exists (true or false). getFirstEntry(query) answers the first match or null: a settings or navigation entry, found without its id.

Type your content with generics:

import type { Block, Entry } from '@obelism/content'

type Hero = Block<{ heading: string | null }, 'hero'>
type Page = Entry<{ title: string | null; body: Hero[] | null }, 'page'>

const page = await content.getPage<Page>('home')

The types npx @obelism/content init writes to content/examples/ show each content type's shape.

Shopify

A Shopify field arrives as ShopifyItem ({ id, handle }, a list when multiple). fetchShopifyCollection(item, options) reads the collection with its first first products (default 12, at most 250) from Shopify's Storefront API, and fetchShopifyProducts(items, options) a product field's products, in the field's order (0.17.0 and later):

import { fetchShopifyCollection } from '@obelism/content'

const collection = await fetchShopifyCollection(block.collection, {
	shop: process.env.SHOPIFY_STORE_DOMAIN!,
	storefrontToken: process.env.SHOPIFY_STOREFRONT_TOKEN!,
	first: 12,
	country: 'NL', // optional: the market's prices and currency
	language: 'NL', // optional: translated titles
	init: { next: { revalidate: 60 } }, // optional: passed to fetch
})

Each product has id, handle, title, availableForSale, featuredImage (url, altText, width, height) and priceRange.minVariantPrice (amount, currencyCode). A collection or product the store no longer has (or doesn't sell through the token's channel) is null, or left out of the list. Shopify's errors throw ShopifyStorefrontError. The store's domain and token are in Settings → Copy as .env.

Preview

The canvas loads your site with ?cms_preview=1&cms_entry=…&cms_environment=…&cms_token=…. cms_token is a token for the person editing that reads drafts for 8 hours: read the page with it.

import { ContentClient } from '@obelism/content'

const token = new URL(request.url).searchParams.get('cms_token')
const client = token
	? new ContentClient({
			token,
			space: process.env.CONTENT_SPACE_ID,
			environment: process.env.CONTENT_ENVIRONMENT,
			init: { cache: 'no-store' },
		})
	: deliveryClient

Pass your site's space and environment: anyone with a space of their own can make a cms_token for it, and the delivery API only refuses one for another space when the request names yours. A made-up, expired or other space's token throws a ContentApiError with status 401; fall back to deliveryClient then. readPreviewParams takes a URL, URLSearchParams, a string or a framework's search params object, and answers { entryId, environment } on a request from the canvas, else null. Don't choose a preview token of your own by it: anyone can add cms_preview=1 to a URL.

editable(entryOrBlock) returns the attribute that lets editors select it: data-cms-uid for a block, data-cms-entry for an entry. editableProduct(product) marks the Shopify product a product route shows ({ id, handle }), so editing on the site can add its product page (0.18.0 and later). editableCollection(collection) does the same for a collection route and its collection page (0.20.0 and later); collectionPath on the bridge, like productPath, says where collection pages are on your site.

Bridge

import { getContentBridge } from '@obelism/content/bridge'

const bridge = getContentBridge()
bridge.on('update', ({ entry }) => render(entry)) // unsaved edits, as typed
Option
urlThe CMS, when it isn't content.obelism.studio
promptfalse to leave the prompt box out, or { labels }
pagePath(path, locale): maps a CMS path in the page's locale to the site's URL for New page (default / + the path)
productPath(handle, locale): the site's URL of a product page, by its product's handle (default /products/ + the handle)

Events: update ({ entry }), saved, published, and change ({ features, prompting, canUndo, selectedUid }) for your own prompt UI. Methods: prompt(text), undo(), createPage(path, prompt), createProductPage() (for bridge.product, the product the page marks). Without an update listener the page reloads after each save. See the canvas and prompt editing.

mountPromptBox(document, bridge, { labels }) mounts the prompt box yourself when the bridge was started with prompt: false, and returns a function that removes it.

Editing on the site

createSiteEditing({ spaceId, environment, onSignIn, onSignOut }) returns a handler for everything under /cms (login, callback, logout and the bridge's API calls); readSiteToken(cookies) reads the signed-in editor's token. Other options: url, basePath (default /cms), siteUrl (the public origin when behind a proxy), timeout. See editing on your site.

Your own prompt model

createPromptHandler({ secret, generate }) answers the space's prompt endpoint: it verifies the signed request and returns what generate gives. verifyPromptRequest does only the verification. See prompt editing.

Webhooks

Since @obelism/content 0.6.0. createWebhookHandler({ secret, onEvent }) answers a webhook's URL: it verifies the signature and timestamp (401 when either is wrong), calls onEvent(event, { id }) with the typed event and answers 204, or 500 when onEvent throws so the delivery is retried. id is the delivery's webhook-id, the same on every retry. verifyWebhook(request, secret, { tolerance? }) does only the verification and returns the event, or throws WebhookVerificationError; it also takes { body, headers } for frameworks without a fetch Request. tolerance is in seconds (default 5 minutes).

WebhookEvent is a union on type, so event.data is typed per event: entry.published, entry.unpublished, entry.deleted, entry.moved, asset.updated, model.changed, locales.changed and webhook.test. Entries carry paths (every locale's path) and entry.moved carries previousPaths (0.8.0 and later).

Helpers

  • richTextToHtml(doc, { renderEntry?, renderAsset? }): rich text to safe HTML. Assets default to an image in a <figure>, or a link; entries to nothing.
  • richTextToPlainText(doc)
  • imageUrl(asset, { w, h, fit, format, q }) and imageSrcSet(asset, [480, 960], transform?): see images.
  • alternateLinks(entry, { url, defaultLocale }) and sitemapXml(entries, { url, defaultLocale }): see sitemaps.
  • linkHref(link, { url?, product? }): the address of a link field, see links.
  • isImproveBlock(block) and pickImproveVariant(block, option, fallback?): see A/B tests and flags.
  • fetchShopifyCollection(item, options) and fetchShopifyProducts(items, options): see Shopify.

On this page

Marks in the margin

  • Note
  • Watch out
  • Updated

Scribes marked doubtful lines in the margin. That practice is called obelism.

Stuck on a step?