Skip to content

Keep your content model in git

A content model that lives only in the editor is hard to review, hard to reproduce in staging, and easy to change by accident. content-sdk-apply ships with @ebitex/content-sdk and lets you keep the model in your repository instead, then apply it to any environment.

It reads and writes through the Content Management API. It never decides what changed: it asks the server, which compares your model with what the environment holds right now.

Create a management key in Content, under Settings → Management keys. A key belongs to one authoring environment, so the key you use is the environment you change. There is no environment argument and no --key flag, because a flag lands the credential in shell history and CI logs.

Terminal window
export EBITEX_CONTENT_MANAGEMENT_KEY=…
Command The key needs
plan content.management.read
apply read and content.management.write (plus publish for --publish)
pull content.management.read

Pull the environment out as a model, then plan against it. Nothing should differ:

Terminal window
npx content-sdk-apply pull --model content-model
npx content-sdk-apply plan --model content-model

pull writes one JSON file per item and a content.lock.json. Commit both.

A model names things by the identity the server matches on: an external id, a folder path, a category key. It never contains a Guid, so the same files apply to staging and to production.

model.ts
// Content as code: a content model kept in your repository and applied to an environment with
// `content-sdk-apply`. A model names things by the identity the server matches on (an external id,
// a folder path, a category key) and never by Guid, so the same file applies to staging and to
// production. Save this as `content-model/index.ts` (or `.mjs`) and run:
//
// EBITEX_CONTENT_MANAGEMENT_KEY=… npx content-sdk-apply plan --model content-model/index.mjs
// EBITEX_CONTENT_MANAGEMENT_KEY=… npx content-sdk-apply apply --model content-model/index.mjs
import { defineModel, file, ref } from '@ebitex/content-sdk/model'
export default defineModel({
contracts: [
{
externalId: 'hero-banner',
name: 'Hero banner',
fields: [
{ externalId: 'heading', name: 'Heading', fieldType: 'shortText', isMandatory: true },
{ externalId: 'image', name: 'Image', fieldType: 'blob' },
{ externalId: 'next', name: 'Next banner', fieldType: 'shortText' },
],
},
],
folders: [{ path: 'Library/Heroes' }],
components: [
{
externalId: 'home-hero',
name: 'Home hero',
folder: 'Library/Heroes',
contract: 'hero-banner',
document: {
heading: 'Welcome',
// A local file: the CLI hashes it, asks the server whether it already holds the bytes, and
// uploads it only when it does not. Paths are relative to the model.
image: file('./assets/hero.png'),
// The Guid of another item in this model, wherever a document needs one.
next: ref('component', 'about-hero'),
},
},
{
externalId: 'about-hero',
name: 'About hero',
folder: 'Library/Heroes',
contract: 'hero-banner',
document: { heading: 'About us' },
},
],
})

ref() stands in for the id of another item, and file() for a local file. The CLI hashes each file, asks the server whether it already holds those bytes, and uploads only what is missing.

Terminal window
npx content-sdk-apply plan
npx content-sdk-apply apply

plan needs only the read scope and writes nothing, so it makes a safe pull-request check. It exits with a non-zero code when the plan has blockers. apply writes only the items that differ, and a second run with no changes writes nothing.

content.lock.json records the state of every item the last time you applied. That is how the server tells your repository changed (update) from a person changed it too (conflict). apply refuses a conflict and names every item; pass --confirm-overwrite only when your model should win.

Without the lockfile every difference is a conflict, which is why pull comes first.

If an item you applied before is no longer in the model, plan lists it as an orphan. Nothing removes it. Delete it in Content if it should go.

Plan on pull requests with a read-only key, and apply on merge with a write key. Commit the lockfile back after an apply, or the next run will report conflicts.

- run: npx content-sdk-apply plan
env:
EBITEX_CONTENT_MANAGEMENT_KEY: ${{ secrets.CONTENT_STAGING_READ_KEY }}

To target production, use a second job with a second key.

A key is bound to one realm as well as one environment, so the key you use is also the realm you change: there is no way to pick a realm per request. Pass --realm <key> to make a run stop unless the key acts in the realm you expect. It checks, it does not select. The root realm’s key is root, which is what every key minted before realms acts in.

Terminal window
npx content-sdk-apply plan --realm brand-a

Keep one lockfile per realm, because a state token only means something to the realm that issued it. Name it for the realm: content.lock.json for the root, content-brand-a.lock.json for any other, passed with --lock.

It never deletes content, never merges two edits, and never moves content between environments. For the last, use promotion in Content, or a deployment key from a pipeline (Promote content from a pipeline).