Skip to content

Content API

The Content API manages all content in Conloca. It provides endpoints for listing, creating, updating, and deleting pages and blocks across sites, as well as inheritance operations for multi-locale content management.

All endpoints are relative to the API base URL (default: /__cms/api).

Conventions

  • Etag-based concurrency: Write operations use dual-format etags (metaEtag.contentEtag). Include the current etag to prevent conflicting writes. A 412 Precondition Failed response indicates the content was modified since you last loaded it.
  • Error format: All errors return { error: { code, message, details? } } with a machine-readable code.
  • Content types: Content is either puck (visual editor JSON), mdx (Markdown with JSX), or json (structured data).
  • Content kinds: page (site-scoped, has pathname), block (shared content blocks), or data (structured data entries).

List Collections

GET /content/collections

List all content collections across all sites and blocks.

Parameters: None

Response: 200 OK

{
  collections: string[]
}

Example:

curl http://localhost:4321/__cms/api/content/collections
{
  "collections": ["blog", "components", "pages"]
}

List Content

GET /content

List all content with optional filters. Returns content manifests (metadata without content bodies).

Parameters:

NameInTypeRequiredDescription
kindquery'block' | 'page' | 'data'noFilter by content kind
sitequerystringnoFilter by site name
collectionquerystringnoFilter by collection name
localesquerystringnoComma-separated locale codes
typequery'puck' | 'mdx' | 'json'noFilter by content format
publishedquery'true' | 'false'noFilter by publish status
localizationquery'complete' | 'partial' | 'one'noFilter by localization completeness
missingLocalesquerystringnoComma-separated locales that must be missing

Response: 200 OK

{
  items: ContentManifest[]
  total: number
}

Example:

curl "http://localhost:4321/__cms/api/content?kind=page&site=default"
const response = await fetch('/__cms/api/content?kind=page&site=default');
const { items, total } = await response.json();
{
  "items": [
    {
      "id": "abc123",
      "type": "puck",
      "kind": "page",
      "site": "default",
      "collection": "pages",
      "locales": {
        "en": {
          "locale": "en",
          "etag": "a1b2c3.d4e5f6",
          "created": "2024-01-15T10:00:00Z",
          "modified": "2024-01-20T14:30:00Z",
          "pathname": "/about",
          "meta": { "title": "About Us" }
        }
      }
    }
  ],
  "total": 1
}

Needs Review

GET /content/needs-review

Find content items that need review due to inheritance staleness. When the default locale changes, inherited locales become stale and appear in this list.

Parameters:

NameInTypeRequiredDescription
localequerystringyesLocale to check for staleness
kindquery'page' | 'block'noFilter by content kind

Response: 200 OK

{
  items: ContentManifest[]
  total: number
}

Errors:

StatusCodeDescription
400MISSING_REQUIRED_FIELDlocale query parameter not provided

Example:

curl "http://localhost:4321/__cms/api/content/needs-review?locale=de"

Get Content

GET /content/:id

Get all locales for a content item by ID. Returns the full content manifest with metadata for every locale.

Parameters:

NameInTypeRequiredDescription
idpathstringyesContent ID

Response: 200 OK

// ContentEntry
{
  id: string
  type: 'puck' | 'mdx' | 'json'
  kind: 'block' | 'page' | 'data'
  site?: string
  collection: string
  locales: {
    [locale: string]: {
      locale: string
      etag: string
      created: string
      modified: string
      pathname?: string    // pages only
      name?: string        // blocks/data only
      publishAt?: string
      unpublishAt?: string
      meta: ContentMeta
      content: ContentData
    }
  }
}

Errors:

StatusCodeDescription
404CONTENT_NOT_FOUNDNo content with this ID exists

Example:

curl http://localhost:4321/__cms/api/content/abc123

Get Localized Content

GET /content/:id/:locale

Get content for a specific locale, including the full content body (Puck data, MDX, or JSON). The response includes an ETag header for use in subsequent write operations.

Parameters:

NameInTypeRequiredDescription
idpathstringyesContent ID
localepathstringyesLocale code (e.g., en, de)

Response: 200 OK

Headers: ETag: <metaEtag.contentEtag>

// LocalizedEntry
{
  id: string
  type: 'puck' | 'mdx' | 'json'
  kind: 'block' | 'page' | 'data'
  site?: string
  collection: string
  localized: {
    locale: string
    etag: string
    created: string
    modified: string
    pathname?: string
    name?: string
    meta: ContentMeta
    content: ContentData
  }
}

Errors:

StatusCodeDescription
404CONTENT_NOT_FOUNDContent or locale not found

Example:

curl http://localhost:4321/__cms/api/content/abc123/en
const response = await fetch('/__cms/api/content/abc123/en');
const etag = response.headers.get('ETag');
const entry = await response.json();

Create Content

POST /content

Create a new content item (page, block, or data entry). The authenticated user’s email is automatically recorded for attribution.

Request Body:

// CreateContentInput
{
  kind: 'block' | 'page' | 'data'
  site?: string          // Required for pages
  collection: string
  type: 'puck' | 'mdx' | 'json'
  name?: string          // For blocks and data
  meta?: Partial<ContentMeta>
  locales: {
    [locale: string]: {
      pathname?: string  // Required for pages
      publishAt?: string | null | false
      unpublishAt?: string | null | false
      meta?: Partial<ContentMeta>
      content: ContentData
    }
  }
}

Response: 201 Created

{
  success: true;
  id: string;
  etag: string;
  created: string; // ISO date
}

Errors:

StatusCodeDescription
400METADATA_TOO_LARGEMetadata exceeds size limit
409-Content already exists or pathname is taken

Example:

curl -X POST http://localhost:4321/__cms/api/content \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "page",
    "site": "default",
    "collection": "pages",
    "type": "puck",
    "locales": {
      "en": {
        "pathname": "/new-page",
        "meta": { "title": "New Page" },
        "content": { "puckData": { "root": { "props": {} }, "content": [] } }
      }
    }
  }'
const response = await fetch('/__cms/api/content', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    kind: 'page',
    site: 'default',
    collection: 'pages',
    type: 'puck',
    locales: {
      en: {
        pathname: '/new-page',
        meta: { title: 'New Page' },
        content: { puckData: { root: { props: {} }, content: [] } },
      },
    },
  }),
});
const result = await response.json();

Update Content

PUT /content/:id

Full update of a content item for a specific locale. Requires the current etag for concurrency control. The authenticated user’s email is automatically recorded for attribution.

Parameters:

NameInTypeRequiredDescription
idpathstringyesContent ID
localequerystringyesLocale to update
etagquerystringrecommendedCurrent etag for concurrency check. Strongly recommended — the API accepts requests without it (falling back to an empty string), but omitting it disables conflict detection and may cause stale-write overwrites.

Request Body:

// LocaleUpdateData
{
  pathname?: string           // Pages: update URL path
  name?: string               // Blocks: update name
  publishAt?: string | null
  unpublishAt?: string | null
  meta?: Partial<ContentMeta>
  content?: ContentData
}

Response: 200 OK

Headers: ETag: <newEtag>

{
  success: true;
  etag: string;
  modified: string;
}

Errors:

StatusCodeDescription
400MISSING_REQUIRED_FIELDlocale query parameter missing
404CONTENT_NOT_FOUNDContent not found
412STALE_WRITEEtag mismatch — content was modified by another user

Example:

curl -X PUT "http://localhost:4321/__cms/api/content/abc123?locale=en&etag=a1b2c3.d4e5f6" \
  -H "Content-Type: application/json" \
  -d '{
    "meta": { "title": "Updated Title" },
    "content": { "puckData": { "root": { "props": {} }, "content": [] } }
  }'
const response = await fetch(`/__cms/api/content/${id}?locale=en&etag=${currentEtag}`, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    meta: { title: 'Updated Title' },
    content: { puckData: updatedPuckData },
  }),
});
const result = await response.json();
const newEtag = response.headers.get('ETag');

Partial Update Content

PATCH /content/:id

Partial update of a content item. Behaves identically to PUT — only the fields you provide are updated.

Parameters: Same as PUT /content/:id.

Request Body: Same as PUT. Only include the fields you want to change.

Response: Same as PUT.

Example:

curl -X PATCH "http://localhost:4321/__cms/api/content/abc123?locale=en&etag=a1b2c3.d4e5f6" \
  -H "Content-Type: application/json" \
  -d '{ "meta": { "title": "Just Update the Title" } }'

Delete Content

DELETE /content/:id

Delete a content item entirely, or delete a specific locale. Uses the If-Match header for etag concurrency control.

Parameters:

NameInTypeRequiredDescription
idpathstringyesContent ID
localequerystringnoIf provided, delete only this locale. Otherwise, delete all locales.

Headers:

NameRequiredDescription
If-MatchyesCurrent etag for concurrency check

Response: 200 OK

{
  success: true;
}

Errors:

StatusCodeDescription
404-Content not found
412-Etag mismatch

Example:

# Delete entire content item
curl -X DELETE http://localhost:4321/__cms/api/content/abc123 \
  -H "If-Match: a1b2c3.d4e5f6"

# Delete only the German locale
curl -X DELETE "http://localhost:4321/__cms/api/content/abc123?locale=de" \
  -H "If-Match: a1b2c3.d4e5f6"

Batch Update

POST /content/batch

Update multiple content items in a single request. Each operation in the batch is applied independently — partial failures are possible.

Request Body:

{
  operations: UpdateLocaleInput[]
}

Each operation:

{
  id: string;
  locale: string;
  data: LocaleUpdateData;
  etag: string;
}

Response: 200 OK (all succeeded) or 207 Multi-Status (partial failure)

{
  operations: Array<{
    id: string;
    locale: string;
    updated: boolean;
    error?: { message: string };
    etag?: string;
  }>;
  success: boolean;
  failed: number;
  updated: number;
}

Example:

curl -X POST http://localhost:4321/__cms/api/content/batch \
  -H "Content-Type: application/json" \
  -d '{
    "operations": [
      {
        "id": "abc123",
        "locale": "en",
        "data": { "meta": { "title": "Updated A" } },
        "etag": "a1b2c3.d4e5f6"
      },
      {
        "id": "def456",
        "locale": "en",
        "data": { "meta": { "title": "Updated B" } },
        "etag": "g7h8i9.j0k1l2"
      }
    ]
  }'

Inheritance Operations

Conloca supports content inheritance across locales. Non-default locales can inherit content from the default locale. When the default locale changes, inherited locales become stale and need review.

The typical workflow is:

  1. Content is created in the default locale
  2. Other locales inherit from it automatically
  3. When the default locale changes, inherited locales are flagged for review via /content/needs-review
  4. Editors can review and accept, customize (break inheritance), or revert to inherited content

POST /content/:id/mark-reviewed

Mark inherited content as reviewed after the default locale has changed. Updates the inheritance etag to the current default content etag, clearing the staleness flag.

Parameters:

NameInTypeRequiredDescription
idpathstringyesContent ID
localequerystringyesLocale to mark as reviewed
etagquerystringyesCurrent etag for concurrency check

Response: 200 OK

{ success: true, etag: string }

Errors:

StatusCodeDescription
400MISSING_REQUIRED_FIELDlocale or etag missing
404CONTENT_NOT_FOUNDContent not found
412STALE_WRITEEtag mismatch

Example:

curl -X POST "http://localhost:4321/__cms/api/content/abc123/mark-reviewed?locale=de&etag=a1b2c3.d4e5f6"

POST /content/:id/break-inheritance

Break locale inheritance from the default locale, creating a local copy. After breaking, edits to the default locale no longer affect this locale.

Parameters:

NameInTypeRequiredDescription
idpathstringyesContent ID
localequerystringyesLocale to break inheritance for
etagquerystringyesCurrent etag for concurrency check

Response: 200 OK

{ success: true, etag: string }

Errors:

StatusCodeDescription
400MISSING_REQUIRED_FIELDlocale or etag missing
404CONTENT_NOT_FOUNDContent not found
412STALE_WRITEEtag mismatch

Example:

curl -X POST "http://localhost:4321/__cms/api/content/abc123/break-inheritance?locale=de&etag=a1b2c3.d4e5f6"

POST /content/:id/revert-to-inherited

Revert a locale back to inheriting from the default locale, discarding any local customizations.

Parameters:

NameInTypeRequiredDescription
idpathstringyesContent ID
localequerystringyesLocale to revert
etagquerystringyesCurrent etag for concurrency check

Response: 200 OK

{ success: true, etag: string }

Errors:

StatusCodeDescription
400MISSING_REQUIRED_FIELDlocale or etag missing
404CONTENT_NOT_FOUNDContent not found
412STALE_WRITEEtag mismatch

Example:

curl -X POST "http://localhost:4321/__cms/api/content/abc123/revert-to-inherited?locale=de&etag=a1b2c3.d4e5f6"

Type Reference

ContentMeta

interface ContentMeta {
  title: string;
  description?: string;
  author?: string;
  tags?: string[];
  keywords?: string[];
  ogTitle?: string;
  ogDescription?: string;
  ogImage?: string;
  [key: string]: any; // Additional custom fields
}

ContentData

interface ContentData {
  puckData?: any; // Puck visual editor data
  mdx?: string; // MDX source
  data?: Record<string, unknown>; // Structured JSON data
}

Error Codes

CodeDescription
CONTENT_NOT_FOUNDContent item does not exist
STALE_WRITEEtag mismatch — content was modified since last read
MISSING_REQUIRED_FIELDA required parameter is missing
ALREADY_EXISTSContent with this ID already exists
PATHNAME_TAKENAnother page uses this pathname
METADATA_TOO_LARGEContent metadata exceeds the size limit
WRITE_ERRORFile system write operation failed
INTERNAL_ERRORUnexpected server error