@bifrost-orbit-cms/sdk
v0.11.1
Published
Read content from CMS Orbit.
Maintainers
Readme
@bifrost-orbit-cms/sdk
CMS Orbit has official SDKs for four languages. Each one is a typed client for the same delivery API, with the same calls for pages, menus, global content, lists, campaigns, products, regions, sitemaps and preview. npx cms-orbit generate writes types for each of them from your space's content types and components.
| Language | Package | For | Guide |
| --- | --- | --- | --- |
| JavaScript and TypeScript | @bifrost-orbit-cms/sdk on npm | Next.js, React and any Node.js website | This page |
| PHP | bifrost-orbit/cms-sdk with Composer | Laravel, Symfony and plain PHP | PHP SDK |
| Swift | CMSOrbit with Swift Package Manager | iOS and macOS apps, and server-side Swift | Swift SDK |
| Kotlin | com.bifrostorbit:cms-sdk with Gradle or Maven | Android apps and JVM servers, Java too | Kotlin SDK |
Using another language? Read content over the delivery API or GraphQL; both are plain HTTP.
The JavaScript SDK
The JavaScript SDK, @bifrost-orbit-cms/sdk, connects a website to CMS Orbit: a typed client for the delivery API, a React renderer, Next.js helpers for routing, preview, cache refresh and sitemaps, SEO output, a mock client for tests, and a type generator. It is open source under the MIT licence and published on npm.
| Import | For |
| --- | --- |
| @bifrost-orbit-cms/sdk | The client, its errors, cache tags and image addresses |
| @bifrost-orbit-cms/sdk/react | CmsPage, CmsLivePage, useLivePage, CmsBlocks, defineRegistry, BlockProps, CmsImage, RichText, CmsInsights, and hooks for banners, popups and countdowns |
| @bifrost-orbit-cms/sdk/next | Handlers for preview, cache refresh, sitemaps and robots, gone, CmsBridge, and insightsMetadata, insightsRewrites and createInsightsProxy for Insights Orbit |
| @bifrost-orbit-cms/sdk/seo | toMetadata, hreflang, JsonLd and sitemap XML |
| @bifrost-orbit-cms/sdk/testing | createMockClient and fixtures |
| npx cms-orbit generate | TypeScript types from your space's content types and components. Add --lang php, --lang swift or --lang kotlin for the other SDKs |
This page is the reference. For step-by-step guides, start with Connect a website, which builds a complete website file by file, then:
- How your website and CMS Orbit work together
- Render components
- Menus, site-wide content and lists
- Caching and refreshing pages
- Regions and languages
- Metadata, sitemaps and robots with the SDK
- Test your website
- Deploy your website
- Troubleshooting your website
Install
npm install @bifrost-orbit-cms/sdkIt needs Node.js 20 or later. React 19 and Next.js 16 are optional: the client works in any JavaScript runtime, /react needs React and /next needs Next.js. Not using Next.js? See Connect a website without Next.js.
Set up
Create the keys in Configuration › API keys: a public key for published content and, for previews, a secret key.
# .env.local
CMS_API_URL=https://api.bifrostorbit.com/cms
CMS_PUBLIC_KEY=cms_pk_... # published content
CMS_PREVIEW_KEY=cms_sk_... # drafts; server only, staging only
CMS_WEBHOOK_SECRET=... # verifies publish webhooks
CMS_DEFAULT_REGION=germany # the region's key, from Configuration › Environments// lib/cms.ts
import { createCmsClient } from '@bifrost-orbit-cms/sdk'
export const cms = createCmsClient({
baseUrl: process.env.CMS_API_URL!,
apiKey: process.env.CMS_PUBLIC_KEY!,
previewKey: process.env.CMS_PREVIEW_KEY,
defaultRegion: process.env.CMS_DEFAULT_REGION ?? 'germany',
cache: { revalidate: 60, tags: true },
})| Option | What it does |
| --- | --- |
| baseUrl | The API address, https://api.bifrostorbit.com/cms. Required. |
| apiKey | A public key (cms_pk_…) for published content. Required. |
| previewKey | A secret key (cms_sk_…) for drafts. Server only: refused in browser code, so it cannot leak into a public bundle. |
| defaultRegion | Used when a call names no region. |
| cache | Next.js cache settings for published reads, { revalidate, tags }. Previews are never cached. |
| timeout | Per attempt, in milliseconds. 5 seconds by default. |
| retries | Extra attempts after a network error or a server error. 2 by default. |
| mirrorUrl | The space's mirror, from Configuration › API keys, read when CMS Orbit cannot be reached. Learned from CMS Orbit's first answer when not set; false turns it off. See When the CMS cannot be reached. |
| onMirror | Called each time an answer comes from the mirror. Without it, the client warns in the console once a minute. |
| keyInAddress | Sends the public key in the address (apiKey=) instead of a header, so our CDN can answer repeated requests from its cache. On by default in browser code, off on servers, where publish webhooks refresh pages and must reach CMS Orbit itself. Previews always use the header. |
| fetch | Your own fetch, for tests or tracing. |
The language defaults to the region's own; pass locale to ask for another. With only a public key, the client also works in browser code, since the API allows calls from any website.
Types
npx cms-orbit generate --out src/cms/types.tsReads CMS_API_URL and CMS_PUBLIC_KEY, and writes one type per component and content type. Run it in CI so a schema change that would break the website fails the build.
Reference and References fields are typed as ids. If the website reads content with include: ['references'], add --include references and they are typed as the entries instead, such as CmsEntry<FaqEntryFields>[].
Components
// src/cms/registry.tsx
import '@/cms/types'
import { defineRegistry } from '@bifrost-orbit-cms/sdk/react'
export const registry = defineRegistry({ hero: Hero, 'card-grid': CardGrid, faq: Faq })import { CmsImage, RichText, type BlockProps } from '@bifrost-orbit-cms/sdk/react'
export function Hero({ block }: BlockProps<'hero'>) {
return (
<section data-cms-id={block.id}>
<h1>{block.heading}</h1>
<RichText value={block.description} />
<CmsImage image={block.image} sizes="100vw" priority />
</section>
)
}Put data-cms-id={block.id} on each component's outer element. In the live preview, editors click an element with it to select that block, so a component without it cannot be picked from the preview.
Blocks nested in a slot, such as the button in a hero, are on the slot's field, so block.button is the button's node ({ id, type, fields, children, variant }) or null, and a Components slot is a list. The same blocks are rendered for you as slots.button if you would rather place them as they are. See Components and slots in Field types.
Once types are generated, leaving a component out of the registry is a type error. At run time an unknown component renders a note in development and nothing in production. Image fields arrive with their width, height and alt text; links to pages carry the page's current address.
Pages
// app/[region]/[[...slug]]/page.tsx
import { draftMode } from 'next/headers'
import { CmsPage } from '@bifrost-orbit-cms/sdk/react'
import { followResolution } from '@bifrost-orbit-cms/sdk/next'
import { toMetadata, JsonLd } from '@bifrost-orbit-cms/sdk/seo'
import { cms } from '@/lib/cms'
import { registry } from '@/cms/registry'
type Props = { params: Promise<{ region: string; slug?: string[] }> }
async function load({ params }: Props) {
const { region, slug = [] } = await params
const { isEnabled: preview } = await draftMode()
return cms.resolve({ region, path: '/' + slug.join('/'), preview })
}
export async function generateMetadata(props: Props) {
const result = await load(props)
return result.kind === 'page' ? toMetadata(result.page) : {}
}
export default async function Page(props: Props) {
const page = followResolution(await load(props))
return (
<>
<JsonLd page={page} />
<CmsPage page={page} registry={registry} />
</>
)
}toMetadata gives the title, description, self-canonical, robots, Open Graph, X card and hreflang alternates. page.meta says which region and language answered and whether a fallback was used.
followResolution returns the page, or follows what resolve answered instead: a redirect with its own status (308 for a permanent one, such as a trip whose address name changed in Travel Orbit, 307 otherwise), and the not-found page for pages that are gone or missing. Pass localise to add a language folder to redirect targets, such as { localise: (to) => '/no' + to }. Handle the result yourself when you need something else.
Next.js cannot answer 410 from a page, so gone() shows the not-found page. Send a true 410 from proxy.ts where that matters.
Menus and global content
const header = await cms.getNavigation({ key: 'header', region })
const footer = await cms.getGlobal({ key: 'footer', region })
const articles = await cms.list({ type: 'article', region, sort: '-publishedAt', perPage: 12 })Entries without an address, and references
Entries such as FAQ entries or authors have no page of their own. Read them with list and include: ['fields']:
const faqs = await cms.list<FaqEntryFields>({ type: 'faqEntry', region, category: 'pricing', include: ['fields'] })
faqs.items.map((item) => item.fields?.question)Every item has its id. Reference and References fields hold such ids. Pass include: ['references'] to resolve, getPage, getGlobal or list to get the entries they point at instead, with their fields, so a component can show the FAQ entries an editor picked:
const result = await cms.resolve({ path, region, preview, include: ['references'] })export function FaqList({ block }: BlockProps<'faqList'>) {
return block.questions?.map((entry) => <Question key={entry.id} question={entry.fields.question} answer={entry.fields.answer} />)
}References are expanded one level deep, in the language of the request, and only to entries that are published (or drafts, when previewing). Answers read with references are tagged cms:references, and the webhook handler refreshes them whenever content is published, so a page shows an edited FAQ entry straight away. See References in the Delivery API.
Preview
// app/api/preview/route.ts
import { createPreviewHandler } from '@bifrost-orbit-cms/sdk/next'
import { cms } from '@/lib/cms'
export const GET = createPreviewHandler({ client: cms })// app/[region]/layout.tsx, inside <body>
import { CmsBridge } from '@bifrost-orbit-cms/sdk/next'
<CmsBridge client={cms} />The handler checks the CMS preview token, turns on draft mode and redirects to the page. The bridge renders only in draft mode, so editors can select and move blocks in the live preview. createPreviewExitHandler gives a route that leaves preview.
To show changes as editors type rather than after each save, render draft pages with CmsLivePage from a client component:
'use client'
import { CmsLivePage } from '@bifrost-orbit-cms/sdk/react'
import { registry } from '@/cms/registry'
export function LivePage({ page }: { page: CmsPage }) {
return <CmsLivePage page={page} registry={registry} />
}Use it in place of CmsPage when page.draft is true. See Set up the live preview.
In the editor's preview, CmsLivePage and useLivePage also answer the A11y panel's check of the rendered page: they load axe-core, which the SDK depends on, only there and only when asked, and tie each finding to the block marked with data-cms-id. Add data-cms-field="<field key>" to name the field an element shows. Without the live page, call watchAccessibility() from @bifrost-orbit-cms/sdk/react in a client effect. See Accessibility checks in Set up the live preview.
Cache refresh on publish
// app/api/cms-webhook/route.ts
import { createRevalidateHandler } from '@bifrost-orbit-cms/sdk/next'
export const POST = createRevalidateHandler({ secret: process.env.CMS_WEBHOOK_SECRET! })Register the URL as a webhook in the CMS. The handler verifies the signature, refuses messages over 5 minutes old, and refreshes only the pages, menus and sitemaps that changed. See Refresh the website when content changes.
Sitemaps and robots
import { createRobotsRoute, createSitemapIndexRoute, createSitemapRoute } from '@bifrost-orbit-cms/sdk/next'
// app/[region]/sitemap.xml/route.ts
export const GET = createSitemapRoute(cms)
// app/sitemap.xml/route.ts
export const GET = createSitemapIndexRoute(cms)
// app/robots.txt/route.ts
export const GET = createRobotsRoute(cms)Staging always answers with a robots.txt that blocks crawlers.
Popups, banners and countdowns
const { serverTime, items } = await cms.getBanners({ path, region })
const popups = await cms.getPopups({ path, region, loggedIn })Only entries live right now come back, so expired ones never flash on screen. Countdowns arrive worked out: display says whether to show the timer, a follow-up, "Offer ended" or nothing.
'use client'
import { useCountdown, usePopup } from '@bifrost-orbit-cms/sdk/react'
const { popup, dismiss } = usePopup(popups.items) // one per page view, respects hide-for-days and max views
const left = useCountdown(banner.countdown, serverTime) // ticks every second, corrected by the CMS clockTo read banners once for every page, such as in a cached layout, ask with everyPage: true and pick per page with bannerFor(items, path, placement), with the page's address with or without its language folder. See Render banners, popups and countdowns.
Insights Orbit
When the space is connected to Insights Orbit under Settings, each delivered page carries id and insights: { site }. Everything below is opt-in: without a site key, nothing is added to the page.
// app/layout.tsx: the script, once
import { CmsInsights } from '@bifrost-orbit-cms/sdk/react'
<CmsInsights site={process.env.NEXT_PUBLIC_INSIGHTS_SITE} src="/stats/script.js" api="/stats/collect" />// app/[[...slug]]/page.tsx: tag each page view with the entry
import { insightsMetadata } from '@bifrost-orbit-cms/sdk/next'
return { ...toMetadata(page), ...insightsMetadata(page) }| Prop | Means |
| --- | --- |
| site | The site key. Or pass page to take it from the delivery |
| page or contentId | Adds <meta name="insights-content">, so the page's numbers follow it when its address changes |
| src, api | Where the script and the collector are; Insights Orbit's own addresses by default |
| disable | Automatic events to switch off, such as ['scroll'] |
| campaigns | true by default: counts elements with data-banner="LABEL" or data-popup="LABEL", sending Banner viewed once a page and Banner clicked for a link or button inside it (and the same for popups). Put the campaign's trackingLabel there. |
| waitForConsent | false by default. When true, the script counts nothing, reads nothing from the browser and loads nothing until your consent banner calls window.insights.consent(). Call it on every page once the visitor agrees, and window.insights.consent(false) if they withdraw |
To serve it from your own domain, either add insightsRewrites() to rewrites() in next.config.ts, or, to pass on each visitor's address, add a route:
// app/stats/[...path]/route.ts
import { createInsightsProxy } from '@bifrost-orbit-cms/sdk/next'
export const { GET, POST } = createInsightsProxy()It sends the visitor's address in X-Insights-Forwarded-For only when INSIGHTS_PROXY_TOKEN is set, the token from the site's settings in Insights Orbit; keep that on the server. It reads the address from CF-Connecting-IP, X-Real-IP or the first X-Forwarded-For entry, so run it behind a proxy you trust to set them. With trailingSlash: true, use api="/stats/collect/".
Experiments
From version 0.11, CmsPage and CmsLivePage run the space's experiments (A/B tests) with no code: a page with a running experiment carries every variant in page.experiments, and an inline script shows each visitor their variant before the page paints and counts the experiment's goals. To give visitors a variant without cookies, add one line to proxy.ts and set CMS_EXPERIMENTS_SECRET on the server:
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
import { withExperiments } from '@bifrost-orbit-cms/sdk/next/experiments'
export function proxy(request: NextRequest) {
return withExperiments(request, NextResponse.next())
}| Export | From | Does |
| --- | --- | --- |
| CmsExperiments | /react | Adds the script to templates that don't render with CmsPage, so goals on their pages count |
| useExperimentPage(page) | /react | Lays the visitor's variant over the page's own fields |
| convertExperiments(name) | /react | Counts an event, such as 'Booking paid', for experiments with that goal |
| experimentsConsent(given) | /react | Tells experiments whether the visitor accepted cookies |
| withExperiments, experimentSeed | /next/experiments | The daily cookieless seed, in the proxy |
| experimentArm, experimentBucket, experimentVisitorKey, fnv1a | @bifrost-orbit-cms/sdk | The bucketing, for your own server |
How it works, consent, caching, the collector and bucketing for other languages are in Run experiments on your website.
Commerce Orbit
When the space is connected to Commerce Orbit under Configuration › Orbit connections, Product, Products and Collection fields arrive as CmsCommerceProduct and CmsCommerceCollection, with the current price (in minor units, so 18900 in EUR is €189), stock and a link to buy. The Product grid block carries its products in items, and the Collection list block its collections in collections.
import { commercePrice, commerceStock, type CmsCommerceProduct } from '@bifrost-orbit-cms/sdk'
function ProductCard({ product }: { product: CmsCommerceProduct }) {
if (!product.available) return null
return (
<a href={product.url ?? '#'}>
{product.name} {commercePrice(product)} {commerceStock(product) === 'soldOut' ? 'Sold out' : null}
</a>
)
}commercePrice formats the price, or a range when variants cost different amounts. commerceStock returns unavailable, soldOut, low (5 or fewer) or inStock. Pages that show a product refresh through createRevalidateHandler when its price, stock or collection changes in Commerce Orbit. See Products and collections from Commerce Orbit.
For trade customers signed in to the shop, commerceStorefront({ baseUrl }) signs them in with an emailed code and fetches their own prices with their customer token, withPersonalPrices(items, prices) lays those over the delivered products, and useCommercePrices(items, load) in @bifrost-orbit-cms/sdk/react does it in the browser after the cached page has loaded. See Prices for signed-in trade customers.
Booking Orbit
When the space is connected to a Booking Orbit location under Configuration › Orbit connections, Service, Services and People fields arrive as CmsBookingService and CmsBookingStaff, with the current price (in minor units, so 45000 in NOK is 450 kr), length, photo and next free time. Every Booking Orbit block also carries booking (CmsBookingLocation), with the address and location that the Booking Orbit SDK needs to take the booking on your page.
import { bookingLength, bookingPrice, type CmsBookingService } from '@bifrost-orbit-cms/sdk'
function ServiceCard({ service }: { service: CmsBookingService }) {
if (!service.available) return null
return (
<a href={`/services?service=${service.slug}`}>
{service.name} {bookingLength(service.durationMinutes)} {bookingPrice(service, 'en-GB')}
</a>
)
}bookingPrice formats the price, with From before a starting price and Free (or the word you pass) for a free service. See Appointments from Booking Orbit.
GraphQL
const data = await cms.graphql<{ page: { name: string } | null }>(
'query ($url: String!) { page(url: $url) { name } }',
{ variables: { url: '/gear/fjell-gtx-boot' } },
)Pass the expected shape as the type. preview: true sends the preview key; ask for drafts in the query itself, as the GraphQL reference shows. Errors in the answer are thrown, not returned beside partial data. See the reference for the full schema.
Client methods
| Method | Answers with |
| --- | --- |
| resolve | A page, a redirect, gone or not found for an address. Call it before rendering. |
| getPage, findPage | One page by address. find… answers null instead of throwing when nothing is published there. |
| list | Entries of one content type, filtered and paged, with their fields when asked |
| getGlobal, findGlobal | Site-wide content such as a footer, by its key |
| getNavigation, findNavigation | A menu, by its key |
| getBanners, getPopups | Banners and popups live now for a page |
| getFlags | The environment's feature flags, such as { 'new-booking-form': true }. See Use feature flags. |
| getRegions, getLocales | The regions and languages the key may read |
| getSitemap, getSitemapIndex, getRobots | Sitemaps and robots rules |
| getProducts, getTaxonomy | Shop products with live prices, and taxonomy terms |
| graphql | Any GraphQL query |
| getSchema | Content types and components, used by the type generator |
Every method takes region, locale and preview where they apply, include on resolve, getPage, getGlobal and list, and a signal to cancel. See the Delivery API for what each answer contains.
Errors
Failed calls throw a CmsError, or one of its kinds: CmsNotFoundError, CmsAuthError for a missing or wrong key, CmsRateLimitError (with retryAfter in seconds) and CmsUnavailableError. Each has the HTTP status and a code. Network errors and server errors are retried twice with back-off, and each attempt times out after 5 seconds. After that, and after CmsRateLimitError, published content is read from the space's mirror, so the call only throws when the mirror has no copy either; isStale(answer) is true for answers from the mirror. See When the CMS cannot be reached.
When your organisation's CMS Orbit trial or plan has ended, delivery is paused: calls throw CmsSubscriptionInactiveError (status 402, code subscription_inactive) straight away, without retrying, and the mirror is not used, since CMS Orbit removes it while delivery is paused. Check with isSubscriptionInactive(error). Nothing is deleted, and content comes back as soon as an owner chooses a plan.
In Next.js, read pages through whilePaused(load, { client }) from @bifrost-orbit-cms/sdk/next. It returns { paused: true } instead of throwing, so the page renders <CmsPausedPage name="Northwilds" /> and pausedMetadata({ name: 'Northwilds' }), and Next.js caches the paused page in place of the old one for 30 seconds. createRevalidateHandler refreshes every page when CMS Orbit sends delivery.paused or delivery.resumed. See When the trial or plan ends.
Tests
import { createMockClient, fixtures } from '@bifrost-orbit-cms/sdk/testing'
const cms = createMockClient({
defaultRegion: 'de',
pages: { '/de/gear/fjell-gtx-boot': fixtures.page({ url: '/gear/fjell-gtx-boot' }) },
})The mock answers from what you give it, and fixtures builds realistic answers so a test only states what it cares about. Give it entries: [fixtures.entry({ contentType: 'faqEntry', fields: { … } })] to answer list, with fields when include asks for them.
Versions
The version history is in the package's CHANGELOG.md. Breaking changes are listed under Breaking, with what to change.
Code explanations
Comments such as // [EXPLAIN-D84] in the package point to an entry in its EXPLAIN.md. The file is included in the package, and can also be read at unpkg.com/@bifrost-orbit-cms/sdk/EXPLAIN.md.
