# JavaScript SDK (/docs/sdk/javascript)

```sh
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](https://github.com/Obelism/sdk/tree/main/packages/content).

| 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](/docs/sdk/cli)                                                            |

## ContentClient [#contentclient]

Reads one space environment through the [delivery API](/docs/api/delivery). 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.

```ts
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](#without-a-stored-token) |
| `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 [#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](https://www.obelism.studio) ([how](/docs/guides/api-tokens#without-a-stored-token)).

```ts
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](/docs/guides/api-tokens#local-development).

### Paths per locale [#paths-per-locale]

Since `@obelism/content` 0.8.0. A page or folder can have a slug per locale (see [Locales](/docs/guides/locales#urls-per-locale)). `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:

```ts
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:

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

### Sitemaps [#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:

```ts
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 [#links]

A [link field](/docs/guides/content-model#links) 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):

```tsx
import { linkHref } from '@obelism/content'

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

A link to a [Shopify product](/docs/guides/shopify#links-to-products) 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):

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

### Filtering and ordering [#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](/docs/api/delivery#filtering-and-ordering):

```ts
// 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:

```ts
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 [#shopify]

A [Shopify field](/docs/guides/shopify) 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):

```ts
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 [#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.

```ts
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](/docs/guides/site-editing) 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 [#bridge]

```ts
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](/docs/guides/canvas) and [prompt editing](/docs/guides/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 [#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](/docs/guides/site-editing).

## Your own prompt model [#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](/docs/guides/prompt-editing#your-own-model).

## Webhooks [#webhooks]

Since `@obelism/content` 0.6.0. `createWebhookHandler({ secret, onEvent })` answers a [webhook](/docs/guides/webhooks)'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 [#helpers]

- `richTextToHtml(doc, { renderEntry?, renderAsset? })`: [rich text](/docs/api/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](/docs/api/images).
- `alternateLinks(entry, { url, defaultLocale })` and `sitemapXml(entries, { url, defaultLocale })`: see [sitemaps](#sitemaps).
- `linkHref(link, { url?, product? })`: the address of a link field, see [links](#links).
- `isImproveBlock(block)` and `pickImproveVariant(block, option, fallback?)`: see [A/B tests and flags](/docs/guides/ab-tests).
- `fetchShopifyCollection(item, options)` and `fetchShopifyProducts(items, options)`: see [Shopify](#shopify).
