Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Reading entries

Content plugin @orthacms/content-server@orthacms/content-admin

Listing, fetching, searching, sorting, paging, locales and sparse fieldsets on the public read endpoints.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Every request below needs Authorization, plus X-Workspace-Id on a multi-workspace token. The full header list is in the overview.

Example language
curl 'https://cms.example.com/api/v1/content/article?pageSize=10&sort=-publishedAt' \
-H 'Authorization: Bearer orthacms_…'
const response = await fetch(
  'https://cms.example.com/api/v1/content/article?pageSize=10&sort=-publishedAt',
  { headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
const { items, total } = await response.json();
query Latest {
  articles(pageSize: 10, sort: "-publishedAt") {
      total
      items {
          id
          title
          slug
          publishedAt
      }
  }
}

Listing

GET /api/v1/content/:typeName

ParameterDefaultEffect
page11-based page number.
pageSize25Rows per page, max 100.
sort-updatedAtA whitelisted column, - for descending.
search—Case-insensitive substring match on text-like fields.
filter—A JSON filter tree. See filtering.
fieldsallComma-separated names to return in values.
localedefault localeWhich language to read, on a localized type.
statuspublisheddraft and any need a full token. See preview.
relations, media, translations—preview turns an expansion on. See expanding.

An unknown query parameter is a 400, not ignored.

{ "items": [ … ], "total": 128, "page": 1, "pageSize": 25 }

Paging through everything:

async function readAll(type) {
    const items = [];
    for (let page = 1; ; page += 1) {
        const response = await fetch(
            `${process.env.ORTHACMS_URL}/api/v1/content/${type}?pageSize=100&page=${page}`,
            { headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
        );
        const body = await response.json();
        items.push(...body.items);
        if (body.page * body.pageSize >= body.total) return items;
    }
}

Sorting

?sort=title          ascending
?sort=-publishedAt   descending
  • Sortable: createdAt, updatedAt, publishedAt, locale, and the type’s own scalar fields.
  • An unknown key falls back to -updatedAt (no error).
  • id is always the final tiebreaker, so paging is stable.
curl -G 'https://cms.example.com/api/v1/content/article' \
  -H 'Authorization: Bearer orthacms_…' \
  --data-urlencode 'search=100%'
  • Matches text, richtext and select fields, case-insensitively.
  • % and _ are matched literally: 100% finds 100%.

Search is ILIKE, not a search engine

No index, no ranking, no stemming, no typo tolerance. On a large table it is a sequential scan. For real search, feed an engine from the API.

Sparse fieldsets

?fields=title,slug,publishedAt
  • Narrows values and the SQL projection — an unselected rich-text column is never read.
  • The envelope is always returned.
  • An unknown name is a 400. So is a relation or media field — expand those instead.

In GraphQL, the selection set does the same thing.

Reading one entry

GET /api/v1/content/:typeName/:id — takes the same fields, locale, status and expansion parameters as the list.

Example language
curl 'https://cms.example.com/api/v1/content/article/9c4b1e77-…?fields=title,body' \
-H 'Authorization: Bearer orthacms_…'
const response = await fetch(
  'https://cms.example.com/api/v1/content/article/' + id + '?fields=title,body',
  { headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
if (response.status === 404) return null;
const entry = await response.json();
query Article($id: ID!) {
  article(id: $id) {
      id
      title
      body
  }
}
# A missing entry is data.article === null, not an error.

A 404 covers all of these alike: missing, in another workspace, soft-deleted, never published, or restricted to segments the reader is not in.

{
    "message": "No published \"article\" entry with id \"9c4b1e77-…\".",
    "error": "Not Found",
    "statusCode": 404
}

Since 0.7.0: an edited live entry keeps serving

Saving an edit to a published entry does not make it 404. Reads return the last published version — its values, its links, status: "published" — until the edit is published. updatedAt is when that published version was written.

Localized reads

?locale=de
  • Absent: the default locale. Unknown slug: 400.
  • No fallback. A record with no German row is missing from a ?locale=de list. localeFallback is not accepted here (400). Ask for the default locale explicitly if you want it.

Addressing by translation group

A record has one localeGroupId across every language. Use it in URLs instead of per-locale ids.

Example language
curl 'https://cms.example.com/api/v1/content/article/group/b2d1…?locale=de' \
-H 'Authorization: Bearer orthacms_…'
const url =
  'https://cms.example.com/api/v1/content/article/group/' +
  encodeURIComponent(groupId) +
  '?locale=' + encodeURIComponent(locale);
const response = await fetch(url, {
  headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN }
});
query ArticleByGroup($group: ID!, $locale: String) {
  article(localeGroupId: $group, locale: $locale) {
      id
      locale
      title
  }
}
  • Without ?locale=, the default locale’s row.
  • No published row in that locale: 404. On a type that is not localized: 400.
RouteReturns
GET …/group/:groupIdThat group’s entry in the requested locale
GET …/group/:groupId/relations/:fieldOne relation field’s links
GET …/group/:groupId/mediaThat entry’s media assets
GET …/group/:groupId/translationsThe group’s other locale rows

Sibling translations

?translations=preview adds the rest of the group — one read builds a language switcher.

curl 'https://cms.example.com/api/v1/content/article/group/b2d1…?locale=en&translations=preview&fields=slug' \
  -H 'Authorization: Bearer orthacms_…'
{
    "id": "9c4b…",
    "values": { "slug": "hello-postgres" },
    "locale": "en",
    "localeGroupId": "b2d1…",
    "translations": [
        { "id": "5e0a…", "values": { "slug": "hallo-postgres" }, "locale": "de", "localeGroupId": "b2d1…" }
    ]
}

(Envelope trimmed.) Siblings are ordered by locale, published only, honour fields, and are not expanded. 400 on a type that is not localized. In GraphQL, select translations { locale slug }.

Single types

A single type (a home page, site settings) is served by the list route — take items[0]. GraphQL gives it one field that returns the record or null.

Example language
curl 'https://cms.example.com/api/v1/content/home_page?locale=de' \
-H 'Authorization: Bearer orthacms_…'
const response = await fetch(
  'https://cms.example.com/api/v1/content/home_page?locale=de',
  { headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
const home = (await response.json()).items[0] ?? null;
query Home {
  homePage(locale: "de") {
      heroTitle
      ctaLabel
      ctaHref
  }
}

Restricted content

With the segments plugin, send X-Reader-Tags from your backend to read as a particular reader. Without it you get only unrestricted entries.

Example language
curl 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Reader-Tags: premium'
// On your server. Tags come from YOUR session, never from the browser.
const tags = session.user?.plan === 'premium' ? ['premium'] : [];
const response = await fetch('https://cms.example.com/api/v1/content/article', {
  headers: {
      Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
      ...(tags.length ? { 'X-Reader-Tags': tags.join(',') } : {})
  }
});
curl -X POST 'https://cms.example.com/api/v1/graphql' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Reader-Tags: premium' \
-H 'Content-Type: application/json' \
-d '{"query":"{ articles { total items { id title } } }"}'

A restricted entry the reader may not see is missing from lists and from total, and a 404 on a single read. The same holds for expanded relations. See request headers for the rules.

Discovering the schema

RouteReturns
GET /api/v1/content-typesEvery type this token can read
GET /api/v1/content-types/:nameOne type’s fields, with kind, validation rules and options
curl https://cms.example.com/api/v1/content-types \
  -H 'Authorization: Bearer orthacms_…'
{
    "items": [
        {
            "name": "article",
            "kind": "collection",
            "label": "Articles",
            "description": "Long-form posts — the reference content type.",
            "publishable": true,
            "paranoid": true,
            "i18n": true
        },
        { "name": "home_page", "kind": "single", "label": "Home page", "description": "The localized landing page.", "path": "/", "publishable": true, "paranoid": false, "i18n": true }
    ]
}

Both routes list only what the workspace can read — its own grants plus types it reads from shared workspaces. Anything else is a 404.

Caching

  • Reads send no Cache-Control. Your CDN’s max-age decides.
  • Each JSON response carries a weak ETag; send it back as If-None-Match for a 304.
  • With X-Reader-Tags, responses differ per reader. Key your cache on the tags, or cache only anonymous reads.
  • To re-render on publish rather than on a timer, use a webhook.