Fetching content

How to read published content, drafts, and collections through the SDK. Everything here works with @estokad/sdk or @estokad/next (which re-exports it).

The client

createClient returns a proxy. Each content type is a property; cms.<type> exposes .get(), .list(), and — with a write key — .create(), .update(), .publish(), .unpublish(), .delete().

import { createClient } from '@estokad/next' // or '@estokad/sdk'

const cms = createClient({
  workspace: 'your-workspace-slug',
  apiUrl: 'https://api.estokad.com',
  apiKey: process.env.ESTOKAD_PUBLIC_KEY, // an ek_r_… read key
})

A read-scope key (ek_r_…) sees published entries only — drafts are invisible to it. Use a read_draft key (ek_d_…) for the preview path (see Drafts).

Which workspace and space am I?

The REST path is /v1/<workspace>/content/…, but a space is a separate concept carried by the key, not the URL — so a slug that's actually a space name yields workspace_not_found. To see exactly what a key can reach, GET /v1/me with the key (any scope):

curl -H "Authorization: Bearer ek_r_…" https://api.estokad.com/v1/me
# → { "scope": "read",
#     "workspace": { "slug": "default", "name": "…", "region": "eu-fra-1" },
#     "space":     { "slug": "hosakaseven" } }

Use workspace.slug in the path; the space rides along on the key.

Entry shape

An entry is metadata plus a data object holding the schema fields. Only id, slug, title, and the timestamps are hoisted; everything else lives under data:

type Entry = {
  id: string
  slug: string | null
  title: string | null // denormalized from the type's displayField
  publishedAt: string | null
  data: Record<string, unknown> // your schema fields
}

So reach through data: article.data.heroLead, not article.heroLead. (title is the exception — it's promoted to the top level and derived from the type's displayField, so it's populated even when the field isn't literally named title.)

Collections

.list({ first, offset }) returns an array. first defaults to 20 (max 100); offset defaults to 0.

const articles = await cms.article.list({ first: 20, offset: 0 })
// paginate:
const page2 = await cms.article.list({ first: 20, offset: 20 })

This is the call for home pages, section indexes, author pages, RSS, and sitemaps.

first caps at 100. Asking for more returns 100 — it does not error. Use listPage() to get the pagination metadata (list() returns the bare array for ergonomics):

const { entries, meta } = await cms.article.listPage({ first: 100, offset: 0 })
// meta: { first, offset, count, hasMore, capped?, maxFirst? }

meta.hasMore is true when another page exists, and meta.capped is true when your requested first was clamped. So a large index must page with offset until hasMore is false rather than assume one big first returns everything. Cursor pagination and where / orderBy filters are on the roadmap (M2.1b); for filtered or nested reads today, use GraphQL.

A single entry

By id, or by slug. Returns null on 404 — ready for generateStaticParams / notFound().

const bySlug = await cms.article.get({ slug: 'annual-report-2026' })
const byId = await cms.article.get({ id: '01HX2…' })

Singletons

A singleton is a content type with one entry. There is no .singleton() helper — read the first (and only) row:

const [settings] = await cms.siteSettings.list({ first: 1 })

Writing (draft → publish)

Writes are two steps. update() (PATCH) saves a draft — it returns 200 with draft: true and does not change the published entry. What a read key sees stays on the last published version until you publish:

await cms.article.update(id, { heroLead: '…' }) // saves a draft → { draft: true }
await cms.article.publish(id) // promotes the draft to published

A read key (ek_r_) only ever sees published content, so a draft you just wrote is invisible to it until publish — that's the scope working as intended, not a lost write. To read an unpublished draft back, use a read_draft key (ek_d_). title and slug are entry-level metadata and update immediately (they don't wait for publish), which is why those "stick" while field data doesn't until you publish.

GraphQL

For nested reads that resolve reference fields to full objects in one round-trip, use cms.gql() against /graphql. Write the query yourself until the codegen pipeline lands.

const { article } = await cms.gql<{ article: Article }>(
  `query ($slug: String!) {
     article(slug: $slug) { title author { name } }
   }`,
  { slug },
)

Drafts

Wire the desk's preview with a read_draft key and @estokad/next/draft. The Studio's preview URL hits app/api/draft/route.ts, which enables Next.js draft mode and redirects to the path; your page then reads through a client configured with the read_draft key so drafts resolve.

// app/api/draft/route.ts
import { estokadDraftMode } from '@estokad/next/draft'

export const GET = estokadDraftMode.enable({
  secret: process.env.ESTOKAD_PREVIEW_SECRET!,
})
export const POST = estokadDraftMode.disable

Revalidation

Pages are statically generated. To publish without a rebuild or waiting out the ISR window, have Estøkad call your app when content changes — see Webhooks for the /api/revalidate recipe. This is the recommended production shape: static pages that keep serving even if Estøkad is unreachable, with publishing the only thing that pauses.