sdk method reference
@chilicms/sdk is the read-side client for the ChiliCMS delivery API. The v0
surface is deliberately small: one factory and three methods. It wraps fetch,
unwraps the { data } envelope, and maps a 404 to null. Nothing else.
This page documents the shipped v0 client (packages/sdk-js). Per-content-type
typed clients (chili.posts.list(...)) land with codegen in a later phase; until
then you pass the content type as a string and supply the row type as a generic.
install
bun add @chilicms/sdkcreateClient
import { createClient } from "@chilicms/sdk"
const chili = createClient({
baseUrl: "https://api.chilicms.dev",
})createClient(opts) returns a ChiliCMSClient. Options:
| option | type | required | notes |
|---|---|---|---|
baseUrl | string | yes | API origin, no trailing slash. A trailing slash is stripped for you. |
fetch | typeof fetch | no | Override the fetch implementation. Defaults to the global fetch. |
The client targets ${baseUrl}/api/v1/<typeSlug>. typeSlug is the URL-plural of
your content type — "posts", "pages", or any type you defined in
chili.config.ts.
the delivery-token header
Every delivery read is fail-closed: the API resolves a per-tenant delivery token
before it opens a row-level-security transaction. A request with no token, a
malformed token, or a revoked token gets a 401 — there is no public fallback
tenant. Unknown, revoked, and suspended all return the same 401, so the response
never tells an attacker which case it hit.
The API reads the token from X-ChiliCMS-Delivery-Token first, then falls back to
Authorization: Bearer <token>. The v0 client does not own a token option yet,
so attach the header through the fetch override:
import { createClient } from "@chilicms/sdk"
const token = process.env.CHILICMS_DELIVERY_TOKEN
const chili = createClient({
baseUrl: "https://api.chilicms.dev",
fetch: (input, init) =>
fetch(input, {
...init,
headers: { ...init?.headers, "X-ChiliCMS-Delivery-Token": token },
}),
})The delivery token is read-only and tenant-scoped: it only ever returns published content for one tenant. Treat it as a secret — read it from the environment, never inline it in client-shipped code.
list
list<T>(typeSlug: string, opts?: { locale?: string }): Promise<T[]>Fetch every published entry of a content type. The API returns a { data: [...] }
envelope; list unwraps it and hands you the array.
type Post = { id: string; title: string; slug: string; locale: string }
const posts = await chili.list<Post>("posts")Pass opts.locale to scope the read to one locale. The value is validated against
the server allowlist — en, de, fr, es, nl, it. An unrecognized locale
is a 400, which surfaces as a thrown error (see error handling).
const dutch = await chili.list<Post>("posts", { locale: "nl" })Omit locale and the request carries no locale query parameter; the API serves
every locale for the tenant.
getById
getById<T>(typeSlug: string, id: string): Promise<T | null>Fetch one entry by its UUID. The id is URL-encoded for you. A 404 returns null
rather than throwing, so a missing entry is a value you branch on, not an exception
you catch.
const post = await chili.getById<Post>("posts", "9f1c…")
if (post === null) {
// not found — render a 404 page
}getById does not take a locale option. An entry's id resolves to exactly one
row regardless of locale.
getBySlug
getBySlug<T extends { slug: string }>(
typeSlug: string,
slug: string,
opts?: { locale?: string },
): Promise<T | null>Fetch one entry by its slug. Returns the first match, or null when nothing
matches.
const post = await chili.getBySlug<Post>("posts", "hello-world", { locale: "en" })A slug is unique per (tenant, slug, locale), so the same slug can exist in more
than one locale. Pass opts.locale to disambiguate — without it, getBySlug
returns whichever locale's entry it finds first.
In v0,
getBySlugcallslistand filters in the client. It carries the cost of a full list per lookup. For hot paths, preferlistonce and index the result yourself; the method routes to a server-side slug index transparently once the API exposes one.
error handling
The client throws on any non-ok HTTP status that is not a handled 404:
| call | condition | result |
|---|---|---|
list | response not ok | throws Error — message includes the type slug and status |
getById | status 404 | returns null |
getById | other non-ok status | throws Error — message includes the slug, id, and status |
getBySlug | underlying list not ok | throws (propagated from list) |
getBySlug | no entry matches | returns null |
A thrown error carries the status in its message — for example
list posts: 401 when the delivery token is missing or invalid, or
list posts: 400 when the locale is off the allowlist. Wrap calls that can
fail at the boundary:
try {
const posts = await chili.list<Post>("posts", { locale: "nl" })
return posts
} catch (err) {
// err.message is e.g. "list posts: 401" (token) or "list posts: 400" (locale)
console.error("delivery read failed:", err)
return []
}method reference
| method | returns | locale | not-found |
|---|---|---|---|
list(typeSlug, opts?) | T[] | opts.locale | empty array |
getById(typeSlug, id) | T | null | — | null |
getBySlug(typeSlug, slug, opts?) | T | null | opts.locale | null |
Next: read the concepts behind the delivery model, or self-host the API the client reads from.