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.
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.
| Import | Has |
|---|---|
@obelism/content | ContentClient, readPreviewParams, editable, rich text, images, types |
@obelism/content/bridge | getContentBridge, mountPromptBox |
@obelism/content/site | createSiteEditing, readSiteToken |
@obelism/content/prompt | createPromptHandler, verifyPromptRequest |
@obelism/content/webhooks | createWebhookHandler, verifyWebhook, the WebhookEvent types (0.6.0 and later) |
npx @obelism/content | the 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| Option | Default | |
|---|---|---|
token | A delivery, preview or site token, or a token source | |
space | Your space: a token for another one is refused | |
environment | Your environment: a token for another one is refused | |
drafts | false | Ask for drafts, with a host token |
url | https://content.obelism.studio | |
locale | the space's default | A locale code, or * for every locale |
depth | 1 | Levels of references to inline, 0–5 |
richText | 'json' | 'html' for rendered HTML strings |
timeout | 10000 | Milliseconds before a request is aborted |
init | Passed to every fetch | |
fetch | global fetch | Your 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 routeA 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' } },
)
}Links
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' },
})
: deliveryClientPass 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 | |
|---|---|
url | The CMS, when it isn't content.obelism.studio |
prompt | false 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 })andimageSrcSet(asset, [480, 960], transform?): see images.alternateLinks(entry, { url, defaultLocale })andsitemapXml(entries, { url, defaultLocale }): see sitemaps.linkHref(link, { url?, product? }): the address of a link field, see links.isImproveBlock(block)andpickImproveVariant(block, option, fallback?): see A/B tests and flags.fetchShopifyCollection(item, options)andfetchShopifyProducts(items, options): see Shopify.