Krantho is in active development and not yet published to npm. Read what that means today.
esc

Search the documentation and product pages. Type “theme” to switch themes.

REST API

The REST API every krantho site serves under /api — authentication, roles, the endpoint groups, and the OpenAPI 3.1 spec and Scalar reference generated from the router.

IntermediateApplies toNext.jsSvelteKitExpress / Vite

Every krantho site serves a REST API under /api. The admin dashboard is a client of it, and so is the krantho CLI. Your own scripts and services can use it the same way: list and edit content, publish, upload media, read SEO data.

The API comes from @krantho/api. You do not wire it route by route — the server package for your framework mounts all of it from one catch-all route:

app/api/[...path]/route.ts
import { createApiRouteHandlers } from '@krantho/server-next'
import config from '../../../krantho.config'
import { collections } from '../../../krantho.collections'

// The content API at /api/*: content, media, SEO, auth and the sitemap
export const { GET, POST, PUT, DELETE, PATCH } = createApiRouteHandlers({
  config,
  collections,
})

// The admin at /admin, in app/admin/[[...slug]]/route.ts:
//
//   import { createAdminRouteHandler } from '@krantho/server-next'
//   export const { GET } = createAdminRouteHandler()
src/routes/api/[...path]/+server.ts
import { createApiHandlers } from '@krantho/server-sveltekit'
import config from '../../../../krantho.config'
import { collections } from '../../../../krantho.collections'

// The content API at /api/*: content, media, SEO, auth and the sitemap
export const { GET, POST, PUT, DELETE, PATCH } = createApiHandlers({
  config,
  collections,
  // Editors preview drafts through a short-lived signed link
  sitePreview: 'signed-url',
})

// The admin at /admin, in src/routes/admin/[...path]/+server.ts:
//
//   import { createAdminHandler } from '@krantho/server-sveltekit'
//   export const { GET } = createAdminHandler()
server.ts
import express from 'express'
import { createAdminServer, createHandler, toWebRequest, writeResponseHeaders } from '@krantho/server-node'
import config from './krantho.config'
import { collections } from './krantho.collections'

const api = createHandler({ config, collections, sitePreview: 'signed-url' })
const admin = createAdminServer(config)
const app = express()

async function send(res: express.Response, response: Response): Promise<void> {
  res.status(response.status)
  writeResponseHeaders(res, response)
  res.end(Buffer.from(await response.arrayBuffer()))
}

const owns = (prefix: string, path: string) => path === prefix || path.startsWith(`${prefix}/`)

// Mounted without a path: the handler routes on the full URL
app.use(async (req, res, next) => {
  if (owns('/api', req.path)) return send(res, await api.handle(await toWebRequest(req)))
  if (!owns('/admin', req.path)) return next()
  const url = new URL(`http://localhost${req.originalUrl}`)
  return send(res, admin({ url, cookie: req.headers.cookie }))
})

The API follows the configuration you pass there: auth decides who may call it, media.provider whether the media routes exist, site.baseUrl where the generated spec says the API lives.

Your API's own reference#

Two routes describe the API of the running site, and neither needs authentication:

RouteWhat it serves
GET /api/openapi.jsonAn OpenAPI 3.1 document generated from the site's router
GET /api/docsA Scalar API reference that loads that document, where you can read and try the routes

The spec covers every route this site registered at startup behind the authentication check — content, versions, schema, SEO and administration, plus the media routes when media is on, site preview when it is enabled, and the routes your plugins add. The public routes for signing in and capabilities are not part of it. Each operation is tagged by group (Content, Versions, Media, SEO and so on). The servers entry is site.baseUrl, and when auth.mode is not none the spec declares bearer and cookie authentication.

The spec names paths, methods and path parameters. Request bodies are declared as plain JSON objects and responses by status code only, so the sections below give the shapes you will use most. Every GET route also answers HEAD, with the same status and headers and no body.

Authentication#

auth.mode picks one of three modes.

ModeReadsWrites
none (default)OpenOpen — every caller is treated as an admin
tokenOpen, unless auth.requireAuthForReads is onA session from the shared secret KRANTHO_API_TOKEN
oidcOpen, unless auth.requireAuthForReads is onA session from your OpenID Connect provider

A session is a signed JWT. The API looks for it first in the krantho_access cookie (the name follows auth.session.cookiePrefix), then in an Authorization: Bearer <token> header. In token mode you exchange the shared secret for a session with POST /api/auth/login; the answer sets the session cookies:

# Log in with the shared secret; the cookies land in cookies.txt
curl -c cookies.txt -X POST http://localhost:3000/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"token":"'"$KRANTHO_API_TOKEN"'"}'

# Who am I?
curl -b cookies.txt http://localhost:3000/api/auth/me

The access token lives for auth.session.accessTtlSec (15 minutes by default). POST /api/auth/refresh exchanges the refresh cookie for a new pair; GET /api/auth/refresh?returnTo=/path does the same for a page navigation and redirects back, or to a new sign-in when the refresh token is no longer valid, and POST /api/auth/logout clears the session. In OIDC mode, GET /api/auth/login/oidc starts the sign-in and GET /api/auth/callback completes it — see The admin dashboard for setting up a provider.

Roles#

Each user has roles: admin, editor or viewer, in that order of rank. A higher role includes the ones below it.

  • viewer — reads. A caller with no session on an open API is an anonymous viewer.
  • editor — creates, updates, publishes and unpublishes content, sees what a publish refreshes, imports documents into a collection, takes and releases their own editing locks, edits, replaces, duplicates and moves media and manages media folders, creates checkpoints, regenerates SEO files and opens site preview.
  • admin — deletes content, restores versions, releases anyone's editing lock, edits runtime schema fields, SEO configuration and enrichment, exports and imports the whole-site bundle, deletes media in bulk and deletes media folders, restores and deletes checkpoints, and manages users, settings, version purging and the webhook delivery log, including retrying a failed delivery.

A few writes ask for a session but no particular role: uploading, registering and deleting a single media asset, and resolving a content migration conflict.

A request without the role it needs gets 403; a write without a session gets 401.

Public routes#

These answer without a session in every mode:

MethodPathPurpose
GET/api/capabilitiesWhat this site supports: persistence, auth mode, features
GET/api/openapi.jsonThe generated OpenAPI 3.1 spec
GET/api/docsThe Scalar API reference
GET/api/auth/capabilitiesThe auth mode and how to sign in
POST/api/auth/loginExchange the shared secret for a session (token mode)
POST/api/auth/refreshRotate an expiring session
GET/api/auth/refreshRotate the session and redirect back (?returnTo=/path), or to a new sign-in
GET/api/auth/login/oidcStart OIDC sign-in (optional ?returnTo=/path)
GET/api/auth/callbackComplete OIDC sign-in
POST/api/auth/logoutEnd the session
GET/api/auth/meThe signed-in identity

Content#

MethodPathPurpose
GET/api/content/:collectionList documents
GET/api/content/:collection/:slugOne document
POST/api/content/:collectionCreate a document; 409 when the slug already has one in that locale
PUT/api/content/:collection/:slugUpdate a document
DELETE/api/content/:collection/:slugDelete a document
POST/api/content/:collection/:slug/publishPublish, unpublish or archive a document
GET/api/content/:collection/:slug/publish-impactWhat publishing a document would refresh
GET/api/content/searchSearch every collection (?q=)

The list takes status, locale, sort, limit and offset as query parameters and answers { items, total }. A single document takes locale, and resolve=true resolves its reference fields. Each document has the shape described in Content fields: meta, fields, body, html and sections.

The content API returns the current version of a document: the newest one, which is what the admin edits. Your pages read the live version through the content loader instead. meta.version is the current version and meta.liveVersion the version visitors see, or null when nothing is live:

meta.liveVersionWhat visitors see
equal to meta.versionThe current version
lower than meta.versionAn older version; the current one has unpublished changes
nullNothing; the document is not published

meta.status follows from the live version: published while a version is live, otherwise draft, or archived for an archived document. When the collection's schema has a status field, it states the same draft or published state; for an archived document it says archived when the schema allows that value, otherwise draft.

Create and update take JSON:

# Create a document; nothing is live yet
curl -b cookies.txt -X POST http://localhost:3000/api/content/pages \
  -H 'Content-Type: application/json' \
  -d '{"slug":"privacy","fields":{"title":"Privacy","description":"What krantho.com stores about its visitors."},"body":"## Scope\n\nThis page covers krantho.com."}'

# Publish it
curl -b cookies.txt -X POST http://localhost:3000/api/content/pages/privacy/publish \
  -H 'Content-Type: application/json' \
  -d '{"status":"published"}'

Fields are validated against the collection's schema; a document that does not pass gets 422 with the problems in details. An update may carry _version — the version you read. If someone saved a newer version in the meantime, the update answers 409 with currentVersion and yourVersion instead of overwriting their work. An update without a body key keeps the body and merges the fields you send into the current ones.

Saving and publishing#

Create and update save: each appends a new current version and never changes what visitors see. A status you send is not used — the document's status is decided by publishing. Both answer with the saved version and the liveVersion, so you can tell whether the document now has unpublished changes:

{ "slug": "privacy", "collection": "pages", "version": 3, "liveVersion": 2, "message": "Updated" }

The publish route changes what visitors see of one document in one locale (?locale=, the site's default locale when absent). Its body's status says what to do:

statusEffect
publishedThe current version becomes live
draftUnpublish: nothing is live
archivedArchive: nothing is live, and the document is kept as archived
absentToggle between published and draft

Each one appends a version, so the history shows who published or unpublished and when. It answers with the document's state afterwards:

{ "slug": "privacy", "collection": "pages", "locale": "en", "status": "published", "version": 4, "liveVersion": 4, "message": "Published" }

POST /api/content/:collection/bulk-publish takes { "slugs": [...] } and an optional locale (the default locale when absent), and publishes the current version of each document on its own; one that fails does not stop the rest. It answers { results, published }, with published: true or an error per slug.

Create and update fire the create or update webhook, publishing fires publish, and unpublishing or archiving fires update; a restore or an import fires none. There is no scheduled publishing.

What a publish refreshes#

GET /api/content/:collection/:slug/publish-impact?locale= (editor) tells you, before you publish, which cached pages and data the publish would refresh:

{
  "collection": "pages",
  "slug": "privacy",
  "locale": "en",
  "paths": ["/privacy"],
  "lists": ["pages"],
  "everyPageReading": false,
  "caching": "next-isr",
  "tags": ["k:d:pages:privacy", "k:c:pages"],
  "cachedEntries": 3
}

paths holds the document's page, empty when its collection renders no pages. everyPageReading is true for such a document, such as a global: every page that reads it refreshes. caching is cache.pages, and tags are the cache tags the publish refreshes. cachedEntries counts the cached pages and data that carry those tags; it is a number only when the instances share their page cache in the database (cache.shared: 'database') and page caching is on, and null otherwise, because krantho does not know which pages a CDN or a single instance holds.

Versions, locks and bulk operations#

MethodPathPurpose
GET/api/content/:collection/:slug/versionsVersion history of a document
GET/api/content/:collection/:slug/versions/:versionOne version
POST/api/content/:collection/:slug/restore/:versionRestore a version as the newest one
POST/api/content/:collection/:slug/lockTake or renew the editing lock
GET/api/content/:collection/:slug/lockWho holds the lock
DELETE/api/content/:collection/:slug/lockRelease the lock
GET/api/locksEvery editing lock currently held
POST/api/content/:collection/bulk-publishPublish several documents
DELETE/api/content/:collection/bulkDelete several documents

A restore is a save: the restored content becomes the current version, and the live version stays as it was. Publish it to make it live.

An editing lock is held by one user per document and locale (?locale= on each lock route), and expires after content.lock.leaseMs. While someone else holds an unexpired lock, saving, publishing, unpublishing, archiving, restoring and deleting the document answer 423:

{ "error": "pages/privacy is being edited by another user", "lockedBy": "editor:ada@example.com", "expiresAt": "2026-10-02T09:15:00.000Z" }

The holder, and anyone once the lock has expired or been released, can write. A bulk delete leaves such a document in place and reports it with deleted: false and the error, and meta-description enrichment skips it. Taking a lock someone else holds also answers 423. An editor can release only their own lock; an admin can release anyone's, such as a lock left behind. Releasing another user's lock as an editor answers 403. GET /api/locks (viewer) answers { locks }, each with collection, slug, locale, lockedBy, lockedAt and expiresAt; expired locks are not listed.

Import and export#

MethodPathPurpose
GET/api/content/:collection/exportExport a collection as JSON, or Markdown with ?format=markdown
POST/api/content/:collection/importImport documents into a collection
GET/api/export/bundleExport the whole site as one bundle
POST/api/import/bundleImport a whole-site bundle

Importing into a collection saves each document, so what visitors see changes only when you publish it. A whole-site bundle also publishes the items it marks published.

Schema, pages and site#

MethodPathPurpose
GET/api/collectionsThe registered collections, with their pages flag and designed sections
GET/api/schema/:collectionA collection's fields, from code and runtime
GET/api/schema/:collection/runtimeOnly the runtime fields
GET/api/schema/:collection/exportThe merged schema as Zod source
PUT/api/schema/:collection/fieldsAdd or update a runtime field; 400 when the definition does not hold, 409 when it changes a field's type
DELETE/api/schema/:collection/fields/:fieldRemove a runtime field
POST/api/validateValidate all content against its schemas
GET/api/pageResolve a URL path to a document (?route=)
GET/api/site-configThe site configuration and the collections

A runtime field can add to a collection but never change a field its code defines; that attempt answers 409. Each collection in /api/collections lists the ## sections its page template designs, in order, as the collection declares them — an empty array when it declares none. See Templates.

Media#

The media routes exist only when media.provider is set — see Media.

MethodPathPurpose
GET/api/mediaList assets (limit, offset, search, folder, tag)
POST/api/media/uploadUpload files as multipart form data
POST/api/media/registerRegister an asset hosted elsewhere by its absolute URL
GET, PUT, DELETE/api/media/:idRead, update or delete one asset
POST/api/media/:id/replaceReplace the file and keep the id
POST/api/media/:id/duplicateCopy an asset
PUT/api/media/:id/moveMove an asset to another folder
GET/api/media/:id/usageDocuments that use an asset
GET/api/media/usage?ids=a,bDocuments that use each of several assets, read in one pass
GET/api/media/:id/imageAn optimized image variant
GET/api/media/:id/image/infoImage dimensions and format
GET, POST/api/media/foldersList or create folders
PUT, DELETE/api/media/folders/:idRename or delete a folder
PUT, DELETE/api/media/bulkUpdate or delete several assets
GET/api/media/tagsTags in use
GET/api/media/infoThe file store in use, and the library's file count, total size and each folder's count

Pages embed the stable image URL, /api/media/:id/image?preset=xl. It redirects to a versioned URL that is cached for a year, so a replaced image shows on the next page view while unchanged images stay cached. preset is xs, sm, md (the default), lg, xl or full; fmt picks avif, webp, jpeg or png, and without it the format is negotiated from the browser's Accept header.

SEO#

MethodPathPurpose
GET/api/seo/statusWhich generated SEO files exist
POST/api/seo/generateRegenerate the files the server can build
GET/api/seo/structured-dataGenerated JSON-LD
GET/api/seo/preview/:collection/:slugThe JSON-LD of one document, in its locale (?locale=)
GET/api/seo/resolved/:collection/:slugEach structured-data property of a document, with its value and where it comes from
GET/api/seo/findings/:collection/:slugOne document's SEO findings
GET/api/seo/findingsThe SEO of every published page
GET/api/seo/sitemapXML sitemap
GET/api/seo/sitemap.jsonJSON sitemap
GET/api/seo/robots.txtrobots.txt naming the sitemap at /sitemap.xml, or the saved override
GET/api/seo/llms.txtllms.txt for AI assistants
GET/api/seo/indexnow/:keyThe IndexNow key file, served at /<key>.txt
GET/api/seo/catalogThe Schema.org archetype catalog
GET, PUT, DELETE/api/seo/configAll SEO configurations
GET, PUT, DELETE/api/seo/config/:idOne SEO configuration
GET/api/seo/collection/:name/impactWhat a collection's configuration affects
POST/api/seo/enrichStart an AI enrichment job
GET/api/seo/enrich/statusProgress of the enrichment job

In /api/seo/resolved/:collection/:slug, each property carries a scope that says where its value comes from: site, collection, route (a URL-pattern configuration), document, computed (derived by krantho from the page and its history) or linked (a reference to another entity).

GET /api/seo/findings (viewer) checks every published page in the default locale, as search engines read it. It answers { pages, readers }: per page its collection, slug and path, its findings counted per reader (readers, each { errors, warnings, info }; title and meta description findings count under page, Schema.org vocabulary findings under schema.org), and the search features its entities can appear in. Each feature names the reader, feature, type and nodeId, its status (active, or restricted when the reader shows it only under conditions), whether the entity is eligible, and the properties it is missing; a feature a reader has retired is not listed. readers lists the site's active readers with their names. Without @krantho/seo the route answers 501.

The sitemap lists the published version of each document in every collection except globals, leaving out pages marked noindex.

The robots.txt override is the robotsTxt of the site's SEO configuration, kept beside its entities, so no page's structured data carries it. An admin saves it with PUT /api/seo/config/site, sending the stored configuration with robotsTxt set, and removes it by saving the configuration without it; any other scope refuses robotsTxt with 400. While it is set, GET /api/seo/robots.txt serves it as written.

GET /api/seo/llms.txt serves the snapshot saved through the assistant when there is one, otherwise a guide generated from the live pages. The x-krantho-llms-origin response header says which: snapshot or generated. A site serves /llms.txt by routing it to this path.

Enrichment#

Enrichment runs as a background job on the server and needs a language model configured under llm — see The SEO pipeline. POST /api/seo/enrich (admin) takes { type, scope, collection, slug, force }:

  • type is meta-descriptions (the default) or archetype, for the Schema.org properties of the document's types.
  • scope is all, collection or item. Without it, a slug means item, a collection alone means collection, and neither means all. A collection scope enriches only that collection's documents, and an item only the document with that slug in that collection.
  • force: true writes over values already set: for archetype the Schema.org properties, for meta-descriptions the document's description field, but never a description held in its SEO configuration. Without it, enrichment fills only what is missing.

It answers 202 with { jobId, status, type, totalItems }, or 200 with jobId: null when the scope has nothing to enrich. An unknown scope, a collection or item scope without a configured collection, an item without a slug, or a language model that cannot be created answers 400; a second job while one runs answers 409. Poll GET /api/seo/enrich/status?jobId= for the progress and each document's outcome.

Enrichment saves each meta description as a new version of the document, and also writes it to the document's SEO configuration unless that already holds a description; archetype writes the Schema.org properties there too. That configuration has no versions, so the live page's meta description and structured data change at once, without a publish. A document someone else holds the edit lock of keeps its meta description.

Administration#

MethodPathPurpose
GET, POST/api/checkpointsList or create checkpoints of the content store
GET, DELETE/api/checkpoints/:versionRead or delete a checkpoint
POST/api/checkpoints/:version/restoreRestore the content store to a checkpoint
GET, POST/api/usersList or create users
GET, PUT, DELETE/api/users/:idRead, update or delete a user
GET, PUT/api/users/:id/permissionsA user's permissions
GET, PUT/api/settings/localesThe site's locales, and which configured ones are active
GET, PUT, DELETE/api/settings/llmLanguage model settings
POST/api/settings/llm/testTest the language model connection
GET/api/versions/statsVersion storage statistics
POST/api/versions/purgePurge old versions, keeping content.versions.keep per document
GET/api/translations/statusTranslation coverage across locales
GET/api/webhooks/deliveriesWebhook delivery log
POST/api/webhooks/deliveries/:id/retrySend a failed delivery again
GET/api/migrations/statusDatabase schema version
GET/api/migrations/conflictsUnresolved content migration conflicts
POST/api/migrations/conflicts/:id/resolveResolve a conflict

GET /api/settings/locales answers { defaultLocale, configuredLocales, supportedLocales, source }. defaultLocale is always site.defaultLocale from the configuration, and every request that names no locale uses it. configuredLocales is site.supportedLocales; supportedLocales are the active ones, either those an admin saved or the configured list. PUT (admin) takes { "supportedLocales": [...] } and answers 400 with the reason when the list is empty, repeats a locale, names a locale the configuration does not list, or leaves out the default locale. The default locale is changed only in the configuration.

POST /api/webhooks/deliveries/:id/retry (admin) puts a failed delivery — one that used all its attempts without being delivered — back in the queue and sends it again at once, signed afresh, to the configured webhook with the same URL. It answers 202 with { delivery }, now pending; the delivery log then shows whether it was delivered or failed again. A delivery that is not failed, or whose URL no configured webhook has any more, answers 409, and a site that logs no deliveries answers 404.

Site preview#

When the site renders drafts for editors, GET /api/preview?collection=&slug= sends an editor to the document's page in preview. @krantho/server-next always turns it on, with Next.js draft mode. SvelteKit and Express sites turn it on by passing sitePreview: 'signed-url' to the handler, which sends the editor through a short-lived signed link. /api/capabilities reports the mode as features.sitePreview, and Studio's Live mode and Open on site use the route when it is enabled. The route takes no locale: a preview shows the document's version in the site's default locale. See The admin dashboard, and Live editing for the marks the admin's Live mode reads from a preview.

Plugins#

Plugins add their own routes under /api/plugins/<plugin name>/, and GET /api/plugins lists the loaded plugins with their routes, admin pages and field types. Plugin routes appear in the generated spec like any other.

Errors#

An error answers with a status code and a JSON body:

{ "error": "Validation failed", "details": [] }
StatusMeaning
400The request is malformed, such as a body that is not a JSON object
401A session is required, or it has expired
403The session lacks the role this route needs, or an editor tried to release another user's lock
404The collection, document, version or asset does not exist
409A conflict: a newer version exists, a job is already running, or a code-defined field was targeted
422The fields failed the collection's schema
423Another user holds the document's editing lock
501The persistence backend does not support this operation