# Build editable pages

> Give editors control of page content for SEO and AEO while keeping your site's templates. Review on staging, then explicitly promote a release to an authorized production channel.


Let editors update copy, images, and page URLs to support SEO and AEO while you
control the design.
Use Kismet to store and review content, and render it with your own templates.
Once a template is installed, editors can create more pages with that layout
without a code deployment.

> **Early access · content channels**
>
> [Request access](mailto:engineering@makekismet.com?subject=Content%20API%20preview) to get started with the Content API and SDK.
> You can publish to your **staging site**; production promotion requires a separate registered production channel and publication grant.

## How it works

1. **Define the fields** each kind of page needs, such as a heading, hero image, and body.
2. **Connect a template** in your site to those fields.
3. **Create a draft** from new or imported content.
4. **Preview and review** a saved version of that draft.
5. **Publish the reviewed version** to staging. Further edits stay in the draft until reviewed and published.

In the API, a *content type* defines the fields, a *draft* holds editable content,
and a *release* is a saved version used for review or publication. Your template
determines how that content looks.

Use your own editing interface and templates. The SDK provides the content
operations below, rather than a visual page editor.

## Set up access

All routes are collection-scoped under
`/v1/developer/collections/{collection}/content`. Use a restricted server key for
configuration, authoring, and review. Keep it in your server environment; never
send it to the browser or accept it from an editor form.

| Capability | What it permits |
| --- | --- |
| `content.read` | Discover type definitions and read published staging pages; resolve an authorized preview |
| `content.drafts.read` | Read channel configuration, drafts, and immutable reviews |
| `content.configure` | Register versioned types and configure the staging origin and template bindings |
| `content.write` | Create/edit drafts, plan/apply imports, create reviews, and restore a review into a draft |
| `content.publish` | Publish an exact reviewed release to the staging channel |
| `content.production.publish` | Configure production together with `content.configure`, promote the current staged release, or restore the preceding production release |

Publishable keys can use `content.read` subject to their installation grants and
authorized origins. They cannot use the privileged operations above. A preview
also requires a valid expiring token bound to its collection, release, and origin;
a hostname or query parameter alone grants no access. Prefer a server reader for
preview routes so tokens are not exposed to analytics, logs, or third-party code.

Content publication is **independent of booking TEST/LIVE**. Both TEST and LIVE
installations can have content permissions. Publication targets the configured
channel; production requires the separate grant and explicit confirmation described
below. Content permissions do not enable bookings or payments.
The examples below run on your server with an installation key. Guest sign-in
does not give a visitor permission to edit pages. If you build an editor, authorize
its users on your server before calling these operations.

## Define fields and connect your template

Import the client from `/server` and content definitions from `/entities`. In this
example, `story` is a content type with a heading, optional image, and body.
`story-layout` is the matching template in your site. Its `pageType: 'CUSTOM'`
identifies a custom page for rendering and structured data; it is not the name
of the content type.

```ts
import { createKismetClient } from '@kismet-tech/sdk/server';
import { defineContentType, parseContentFields } from '@kismet-tech/sdk/entities';
import type { ContentInput } from '@kismet-tech/sdk/entities';

const client = createKismetClient({
  apiKey: process.env.KISMET_SERVER_KEY!,
  collection: 'example-collection',
  baseUrl: 'https://api.ksmt.app/v1',
});
const story = defineContentType({
  id: 'story', version: 1, name: 'Story',
  fields: [
    { name: 'heading', label: 'Heading', kind: 'text', required: true },
    { name: 'hero', label: 'Hero image', kind: 'image', required: false },
    { name: 'body', label: 'Body', kind: 'prose', required: true },
  ],
});
await client.content.types.register(story);
await client.content.channel.configure({
  name: 'staging', origin: 'https://staging.example.com',
  templates: [{
    id: 'story-layout', version: 1,
    contentType: { id: story.id, version: story.version }, pageType: 'CUSTOM',
  }],
});
```

The origin must be a registered non-production domain for the collection and
exactly match an origin authorized for the installation, with no
path, query, fragment, or trailing slash. Install `story-layout@1` in your site's
template registry before using that binding. Registration does not upload or
deploy template code. An unsupported type/template version must show an actionable
error, not silently use a different template.

A type definition is immutable at `id` + `version`; create a new version to change
it. Existing channel origin and template bindings are immutable in this preview;
you may add new versioned bindings. Do not mutate an old definition in place.

Portable fields support `text`, `textarea`, paragraph-only `prose`, `image`, and
`gallery`. Images require HTTPS URLs and alt text; captions are optional. Arbitrary
HTML and vendor block payloads are not accepted. Text limits, 40-image galleries,
100-paragraph prose fields, and a bounded page body are validated. The server
also validates every write against the registered definition.

## Import with a dry run

Capture the source, map it into your declared fields, and compute a SHA-256 hash of
the captured bytes. The API accepts this mapped document; it does not fetch or
crawl the source URL. Verify image rights and hosting, missing fields, links, and
section order before applying the plan.

```ts
// Use the timestamp and SHA-256 hash of the source content you captured.
declare const capturedAt: string;
declare const capturedSha256: string;
const mapped: ContentInput = {
  contentType: { id: story.id, version: story.version },
  template: { id: 'story-layout', version: 1 },
  path: '/our-story',
  fields: parseContentFields(story, {
    heading: 'Our story',
    body: [{ type: 'paragraph', text: 'Welcome to our community.' }],
  }),
  seo: { title: 'Our story', description: 'Meet the people behind your stay.' },
  source: { kind: 'imported', url: 'https://example.com/our-story', capturedAt, sha256: capturedSha256 },
};
const plan = await client.content.imports.plan({
  key: 'our-story', expectedRevision: null, document: mapped,
});
// Review plan.action, plan.changes and plan.input before this separate write.
const draft = await client.content.imports.apply({
  ...plan.input, planDigest: plan.planDigest, idempotencyKey: 'import-our-story-v1',
});
```

`expectedRevision: null` means create a new document. Re-importing an existing
document requires its current revision. Preserve the same idempotency key and
request body when retrying an uncertain create/apply result. A different payload
needs a new key and a new reviewed plan. Stale plans and revisions fail with `409`;
re-read and reconcile rather than overwriting an editor's work. Draft import does
not activate a public route or change production.

For original content, use `client.content.drafts.create({ key, idempotencyKey,
document })` with `source: { kind: 'authored' }`. The import operations specifically
require captured-source provenance.

## Edit, review, publish, and restore

An update replaces the complete `document`; it is not a partial field patch.
Publication and editing are independently authorized operations.

```ts
const edited = await client.content.drafts.update(draft.id, {
  expectedRevision: draft.revision,
  document: { ...mapped, fields: parseContentFields(story, {
    ...mapped.fields, heading: 'Meet our community',
  }) },
});
const review = await client.content.reviews.create(edited.id, {
  expectedRevision: edited.revision, channel: 'staging',
});
// Open review.previewUrl and review its immutable release before publishing.
const published = await client.content.reviews.publish(review.release.id);

// Restore copies a selected release into a new draft; it does not publish it.
const currentDraft = await client.content.drafts.get(edited.id);
const restored = await client.content.reviews.restore(published.id, {
  expectedRevision: currentDraft.revision,
});
// Review restored.revision and explicitly publish that new review if approved.
```

The review saves the content, field definition, template version, page URL, and SEO
together. Later draft edits do not change the review link. Publishing selects
exactly what was reviewed, not the latest draft. If someone has published another
version since your review was created, check their changes and create a new review.
An existing page cannot be replaced by assigning its URL to a different page.
Preview links expire; request a fresh review link when necessary.

## Render one release everywhere

Your staging route reads `cmsPreview` from the review URL and passes that token as
the API's `preview` option. Use the configured origin, not an unchecked host header.

```ts
async function loadStory(path: string, previewToken?: string) {
  const page = await client.content.pages.resolve({
    path,
    ...(previewToken ? { preview: previewToken, origin: 'https://staging.example.com' } : {}),
  });
  if (page.template.id !== 'story-layout' || page.template.version !== 1
      || page.contentType.id !== story.id || page.contentType.version !== story.version) {
    throw new Error('Install the matching content type and template version.');
  }
  const fields = parseContentFields(story, page.release.document.fields);
  return { releaseId: page.release.id, document: page.release.document, fields };
}
const publishedPages = await client.content.pages.list({ limit: 20 });
// Follow publishedPages.page.nextCursor for the next page; each row is a release.
```

`pages.resolve` returns `{ release, contentType, template }`. The release contains
its ID, baseline release ID, creation time, document revision, and pinned type and
template. `pages.list` returns published staging releases, not mutable drafts.
The SDK uses `cache: 'no-store'` for every content request.

Your site must also return `Cache-Control: private, no-store` and
`X-Robots-Tag: noindex, nofollow` for staging content and its Markdown/JSON-LD
representations. Keep preview tokens out of logs, telemetry, referrers and canonical
URLs. Do not add drafts to public sitemaps or `llms.txt`. A plain shared-cacheable
HTML response would defeat the API's privacy headers.

Render HTML, JSON-LD and Markdown from the **same release ID** within a request;
do not combine a reviewed body with live draft SEO or a newer type definition.
If a shared `Kismet.Page` wrapper receives a runtime-selected `KismetPageDefinition`,
pass your validated graph through its `jsonLd` prop as
`{ replaceGeneratedGraph: graph }`. This supports a dynamic page type without a
cast and emits one graph; do not also insert a separate JSON-LD script. `CUSTOM`
pages still require an explicit graph.
Render prose as escaped text, not raw HTML. Configure image hosting for your chosen
renderer. A second page of the same type should use the same dynamic route and
template registry, without a new per-page component or code deployment.

## REST example and recovery

```sh
curl --request GET \
  "https://api.ksmt.app/v1/developer/collections/example-collection/content/pages/resolve?path=%2Four-story" \
  --header "Authorization: Bearer $KISMET_SERVER_KEY" \
  --header "Accept: application/json"
```

| Failure | Recovery |
| --- | --- |
| `400` invalid fields, version, origin or route | Correct the named input; do not retry unchanged |
| `401` / `403` credential or grant failure | Check the installation, collection, capability and permitted origin; a guest session is not an editor session |
| `403` invalid, expired or wrong-origin preview | Obtain a fresh authorized review link; do not fall back to the draft |
| `404` missing document, release or published route | Discover/read within the same collection and use the returned identifiers |
| `409` stale revision, plan, baseline or route conflict | Re-read and reconcile, then create a new plan/review; never silently retry against latest |
| `429` / `503` temporary service failure | Respect backoff; preserve idempotency keys for uncertain create/apply results |

See the **Content (preview)** API reference for request fields, response schemas,
and permissions. Each operation also has a Markdown version for coding agents.


## Promote reviewed content to production

The staging and production channels have independent publication pointers. Editing a draft or publishing to staging leaves production unchanged. Register production with `content.channel.configure` using `name: 'production'`, the collection's registered production origin, and the exact renderer bindings used on staging. This requires both `content.configure` and `content.production.publish`; a manager using OAuth must be an administrator. Staging and production must use different origins. No existing credential gains production authority automatically.

The renderer deployment must already support the registered template. Promotion switches content only; it does not deploy code, configure DNS, or prove that the deployed renderer matches its declared version. Complete that integration check before connecting a real production domain.

```ts
const productionStatus = await client.content.production.status(draft.id);
if (!productionStatus.staging || !productionStatus.origin) throw new Error('Connect production and publish to staging first.');
// Show the destination, staged revision, and current production revision to the publisher.
// After explicit confirmation, promote this exact release. Preserve the request key for retries.
const productionRequest = {
  expectedProductionVersion: productionStatus.version,
  expectedProductionOrigin: productionStatus.origin,
  idempotencyKey: 'promote-our-story-reviewed-v1',
};
await client.content.production.promote(productionStatus.staging.id, productionRequest);
const livePage = await client.content.pages.resolve({path: '/our-story', channel: 'production'});
const liveIndex = await client.content.pages.list({channel: 'production'});
```

The server rejects stale staging releases, changed production versions, destination mismatches, incompatible template bindings and conflicting routes. The promotion uses the reviewed snapshot even if the draft has since changed. The returned release keeps its original staging review identity; selecting `channel: 'production'` chooses which publication pointer delivers it. Omitted channel selection continues to mean staging for installed clients.

To restore the preceding production release, load fresh status, show the previous and current revisions and destination, then confirm the rollback:

```ts
const rollbackStatus = await client.content.production.status(draft.id);
if (!rollbackStatus.previous || !rollbackStatus.origin) throw new Error('No previous production release.');
await client.content.production.rollback(draft.id, {
  expectedProductionVersion: rollbackStatus.version,
  expectedProductionOrigin: rollbackStatus.origin,
  idempotencyKey: 'restore-our-story-previous-v1',
});
```

Rollback also requires `content.production.publish`. It updates only production; it leaves staging and draft edits intact. Production versions increase on each change, including restores, so old confirmations cannot accidentally be reused. Use production selection consistently for HTML, Markdown, JSON-LD, sitemap and LLM discovery so all surfaces share the same release ID.
