Skip to content
commitpress Open CMS

SDK

Read your content from a build, and write it from a script. The JavaScript package is the one that exists today; what it reads is ordinary JSON in your repository, which is what any other language would read too.

Install

One dependency, in the project that renders your site. There is no client to configure and no key to hold: reads hit the filesystem, so there is no network call, no API key and no rate limit.

terminal
npm install @commitpress/sdk-node

# The SDK reads content off disk at build time. It is a
# dependency of your site, not of the CMS.

Generate types

Your schemas are the source of the types. Generation reads them off disk and writes one module that wraps the SDK with your slugs baked in, so the return type of a query follows from its argument rather than from a generic you have to keep correct by hand.

terminal
npx commitpress generate

# Reads every schema under __commitpress__/schema and
# writes typed content shapes to the path declared as
# "sdk": { "output": … } in commitpress.config.json.
#
# Re-run it after a schema change — or wire it into
# your build step, which is what stops the two from
# drifting silently.

Query one item

src/routes/index.ts
import { query } from './commitpress.generated'

const page = await query('content/pages/index')

page.slug            // 'index'
page.status          // 'published'
page.last_edited     // 1757613743102
page.content.hero.heading
//   ^? string — the type comes from your schema

A draft throws rather than returning nothing, and says so: an unpublished page rendering as an empty one is the failure nobody notices until it is live. Pass includeDrafts when that is what you meant.

The package works without the generated module too — the same functions, with the shape supplied by you.

src/routes/index.ts
import { query } from '@commitpress/sdk-node'

// Without the generated module: the same function, with
// the shape supplied by you rather than by your schema.
const page = await query<{ title: string }>(
  'content/pages/index',
  process.cwd(),   // where __commitpress__ lives
)

Query a collection

Collections come back as a list. Drafts are excluded unless you ask for them, so a build that says nothing about drafts publishes too little rather than too much.

src/routes/blog.ts
import { queryList } from '@commitpress/sdk-node'

// Every entry in a collection, published only.
const posts = await queryList('content/collections/posts')

// A preview build is the one place that opts in:
const all = await queryList(
  'content/collections/posts',
  process.cwd(),
  { includeDrafts: true },
)

The documentation you are reading is exactly this call. Every page under /docs is one entry of a docs collection in this repository, and the sidebar is the list — which is why adding a page here is a content edit rather than a deploy.

Blocks that are off

An editor can switch one row off instead of deleting it — the content stays in the file and is not meant for the site. It is the same decision as unpublishing a page, one level down, and it is stored two ways because the two shapes it applies to are not the same shape.

A block is a one-key object naming which variant it is, so there is a name to change: a block that is off is filed under $off:hero rather than hero. Every renderer of a multiblock looks each key up in a table of components, so a block that is off finds no component and draws nothing — your site already does the right thing, with no change and no upgrade of this package.

An item of a repeating group has no such name. It is a bare object of that group's fields in an array of its siblings, so it carries $off: true among them, and nothing about the row's own shape can hide it from a loop that renders every element of an array. So query() and queryList() drop those rows on the way out — your existing loops render the right rows, again with no change to your site. Pass { includeOff: true } to read the file unfiltered.

Off blocks are left where they are by that filter, on purpose: they already draw nothing, and removing them would renumber the sections after them — which is what the preview's section paths are built from, so click-to-edit would start opening the section below the one that was clicked.

src/lib/blocks.ts
import { blockEntry, liveItems } from '@commitpress/sdk-node/blocks'

// A repeating group: nothing to do. The rows that are off were already
// dropped by the read, so this loop draws what is on.
for (const card of page.content.cards) {
  renderCard(card)
}

// `liveItems` is for values that did not come through a read — the ones
// an editor posts into a preview frame, say — or for a build that asked
// for the file with { includeOff: true }.
for (const card of liveItems(postedValues.cards)) {
  renderCard(card)
}

// A multiblock: a lookup by name already skips what is off. `blockEntry`
// is for when you want to know — a preview build that shows what turning
// one back on would look like, the same override `includeDrafts` is.
for (const block of page.content.sections) {
  const found = blockEntry(block)
  if (!found) continue
  if (found.off && !preview) continue
  render(components[found.name], found.value)
}

Write content

Imports, migrations and generated content do not want a browser. The same package writes the files the editor writes, envelope and all, so an item created from a script is indistinguishable from one typed into the CMS.

scripts/import.ts
import { writeContent, bulkWrite } from '@commitpress/sdk-node'

// One item. Creates or updates, and stamps the envelope
// — version, status, timestamps — for you.
await writeContent({
  slug: 'content/collections/posts/hello-world',
  schema: 'post',
  status: 'draft',
  content: { title: 'Hello world', body: '<p>…</p>' },
})

// A migration or an import: a worker pool, so 100,000
// records is a batch rather than 100,000 awaits.
const result = await bulkWrite(items, {
  concurrency: 50,
  onProgress: ({ done, total }) => console.log(done, '/', total),
})

The same operations are available from the command line, which is usually what a one-off migration actually wants.

terminal
npx commitpress write --slug content/pages/about --schema page --data ./about.json
npx commitpress bulk  --file ./posts.jsonl
npx commitpress delete --slug content/pages/old-page

# And the export, for a site that reads Markdown or YAML
# rather than calling the SDK at all:
npx commitpress transform --format markdown --out ./dist-static

These write files; they do not commit them. Whatever runs the script owns the commit — which is the point of content that is just files in your repository, and the reason a bulk import is reviewable as a diff.

Other languages

There is one SDK today and it is JavaScript. It is worth being precise about what that does and does not rule out, because the package is a convenience over something entirely open: your content is JSON files in your own git repository, in a documented envelope, checked out beside your code at build time.

terminal
# The file the SDK reads. Nothing about it is private:
cat __commitpress__/content/pages/index.json

{
  "v": 2,
  "slug": "index",
  "schema": "marketing",
  "status": "published",
  "published_at": 1757613743102,
  "last_edited": 1757613743102,
  "content": { "hero": { "heading": "…" } }
}

So a Hugo, Astro, Rails or Go build reads them with whatever it already uses to read JSON. What such a build gives up is the generated types and the published/draft filtering, both of which are a few lines: the envelope carries status, and "published" is the only value that means live.

If JSON is the wrong shape for your generator, commitpress transform exports the same content as Markdown with YAML frontmatter, HTML, plain JSON or YAML data files — which is the path for a static site generator that expects a content folder rather than an API.

Ready to connect a repository?

The control panel walks through installing the GitHub App and writing your first config.

Cookie settings

commitpress sets no advertising or analytics cookies, and no third party sets any through this site. What is stored is listed below.

Strictly necessary — always on
  • Keeping you signed in, and the token that stops another site submitting forms as you
  • The project and branch you are working on, and a short-lived cache of its configuration
  • One-off status messages, deleted the moment they are shown
  • This choice
Accept all