Kwisple
Browse the docs

Content and data

Querying entries

Every option of EntriesQuery: sections and types, field filters and their operators, sorting, pagination, picking fields, locales, trees and related entries, with examples for the common tasks.

client.entries(query) and GET /api/v1/entries take the same query; the SDK only serialises it. This page lists what the query engine (findEntries in @kwisple/content) understands and how it behaves. The REST spelling is in Delivery API, the method signatures in SDK client.

interface EntriesQuery<H extends string = string> {
  section?: H | H[];
  type?: string | string[];
  locale?: string;
  id?: string | string[];
  slug?: string;
  parentId?: string | null;
  level?: number;
  search?: string;
  relatedTo?: string;
  orderBy?: "postDate desc" | "postDate asc" | "title asc" | "structure" | "updatedAt desc" | `${string} asc` | `${string} desc`;
  limit?: number;
  offset?: number;
  where?: Record<string, FieldFilter>;
  select?: string[];
}

What is returned

Always live content: the entry is enabled in that locale, postDate is empty or in the past and expiryDate is empty or in the future. Disabled, scheduled and expired entries never appear, whatever you filter on. Drafts are only reachable through client.entry() with a preview token (see Live preview and revalidation).

The answer is { items, total }. Each item is the entry's metadata plus its fields flattened onto it:

Always presentMeaning
id, section, typeidentity; type is the entry type handle (discriminate on it)
title, slug, uri, urluri is the stored URI (__home__ for the homepage, null without URI format); url the public path with locale prefix (/over-ons, /fr/a-propos) or null
localethe translation you got
postDate, expiryDate, updatedAtISO strings or null; updatedAt is the later of the entry and its translation
parentId, levelstructure position (level starts at 1)
seo{ title, description, image, noindex, canonical }; title falls back to the entry title, image is a resolved Asset or null
…fieldsone property per field handle of the entry type, resolved (see below)

Field values arrive resolved, not as stored ids:

  • Assets (f.assets): an array of Asset ({ id, url, kind, mimeType, filename, width, height, alt, focalPoint, placeholder }); a per-use alt text overrides the asset's own. Missing assets are dropped.
  • Related entries (f.entries): an array of EntryRef ({ id, section, type, title, slug, uri }). Only live targets are returned; the ref carries no url, build the path from uri (/${uri}, with the locale prefix for other locales) or fetch the entries by id when you need their fields.
  • Links (f.link): { type, href, text, newWindow, entry?, asset? } with a ready href (mailto:, tel:, the entry's path, the asset URL). A link to an entry that is not live resolves to null.
  • Matrix (f.matrix): an array of blocks { id, type, title, …fields } with their own fields resolved the same way. Disabled blocks are left out.
  • Everything else as stored (text, rich text as sanitised HTML, numbers, booleans, select values, dates as ISO strings, table rows, …). See Fields reference.

Narrowing

OptionBehaviour
sectionone handle or a list. With one section the SDK types the items as that section's entries
typeone entry type handle or a list; combine with section for exact typing in matrix-heavy models
idone id or a list; an empty list returns nothing
slugexact match on the slug in the requested locale
parentIdstructures: direct children of that entry; null = top level (REST: parentId=null or an empty value)
levelstructures: entries at exactly this depth
searchcase-insensitive "contains" on the title and the entry's indexed text (plain text and rich text fields); not ranked
relatedToentries that reference the given entry or asset id anywhere in their fields (relations are indexed on publish). Pairs well with a section filter: "news that mentions this product", "pages that use this image"

Field filters: where

where is an object keyed by field handle; every condition must match (AND). A value means "equals"; an object holds operators.

where: {
  tags: "music",                 // multiple select / related entries: contains this value
  featured: true,                // toggle
  price: { gte: 10, lt: 50 },    // number
  date: { gte: "2026-11-01" },   // date, ISO string
  venue: { exists: true },       // has a value
  subtitle: null,                // is empty
  city: { in: ["Gent", "Brugge"] },
  title: { contains: "opening" } // not allowed: title is not a field; use `search`
}

Over REST: where={"price":{"gte":10},"tags":"music"} (URL-encoded JSON).

Operators

OperatorValueMeaning
(bare value) or eqstring, number, booleanequals; on a list field (multiple select, related entries) "contains this value"; on a toggle true/false
nestring, number, booleannot equals (the negation of eq, so an empty field counts as "not equal")
inarrayany of these (an empty array matches nothing)
gt, gte, lt, ltestring or numbercomparison. Numbers compare numerically; everything else compares as text, which works for ISO dates (2026-11-01 sorts before 2026-11-02)
containsstringcase-insensitive text search in that field
existsbooleantrue: has a value; false: empty
null (as the whole filter)—empty, same as { exists: false }

"Empty" means null, "", [] or false.

Which fields can be filtered

Filters and field sorting are validated against the content model: the handle must exist in an entry type of the queried sections (and types, when given), and its kind must be one of plainText, number, toggle, select, date, color, email, url or entries. Anything else (rich text, assets, matrix, table, link, …) and unknown handles return 400 bad_request with the reason in Dutch, for example Onbekend veld "foo" in where. or Op veld "body" (richText) kan je niet filteren of sorteren.. The SDK throws that as KwispleError.

The value of a field is looked up wherever it is stored (translated per locale or shared), so filtering on a translation: "none" field works in every locale.

Sorting: orderBy

ValueOrder
(omitted)postDate desc, then creation date descending
postDate desc / postDate ascby publish date
title ascalphabetical in the requested locale
structurethe editor's order in a structure section (level, then position)
updatedAt descmost recently changed first
"<field> asc" / "<field> desc"by a filterable field (same kinds as where); entries without a value come last, ties are broken by title

Only one ordering per query. "<field>" without a direction, or a third word, is a 400.

Pagination: limit, offset, total

  • limit defaults to 100 and is clamped to 1…500. Asking for 1000 silently gives 500; page with offset instead.
  • offset defaults to 0.
  • total is the number of matching entries regardless of limit/offset, computed in the same request. There is no cursor API.
const pageSize = 12;
const page = Number(searchParams.page ?? 1);
const { items, total } = await cms.entries({
  section: "news",
  limit: pageSize,
  offset: (page - 1) * pageSize,
});
const pages = Math.ceil(total / pageSize);

Picking fields: select

select: ["intro", "image"] resolves and returns only those field handles; the metadata listed above is always included. Use it on listings: assets and relations in unselected fields are not even looked up, which keeps the answer small and the query fast. The TypeScript type does not narrow (the item type still lists every field), so treat unselected fields as absent.

Locales

  • locale picks the translation; omitted means the site's default locale (site.defaultLocale).
  • An entry that has no content row in that locale is not returned. There is no fallback to the default locale: a page that only exists in Dutch does not appear in a French listing. Model that in the editor (translate or leave the entry disabled in that locale), not in the query.
  • url and link hrefs carry the locale prefix for non-default locales. client.entryByPath() derives the locale from the request path for you. See Localisation and URIs.

One entry: client.entry() and entryByPath()

entry({ uri | id, locale }) is findEntry: the same query with limit: 1, null when nothing is live. It is the only read that honours a preview token. entryByPath(path) turns a request path into { locale, uri } first (locale prefix, pathToUri, / → __home__) and then calls entry. For the homepage use uri: "__home__".

Trees: client.tree()

tree(section, { locale?, maxLevel? }) returns the structure as nested TreeNodes ({ id, section, type, title, slug, uri, url, children }) in the editor's order, live entries only, from at most 500 entries of the section. maxLevel: 2 keeps two levels. Nodes have no field values: for a mega menu with images, query entries({ section, level: 1, select: ["menuImage"] }) alongside.

Examples

A news listing with pagination and small items

const { items, total } = await cms.entries({
  section: "news",
  orderBy: "postDate desc",
  limit: 24,
  select: ["intro", "image"],
});

A page by request path (catch-all route)

// Next.js: app/[[...path]]/page.tsx
const api = await cms();
const { entry } = await api.entryByPath(`/${(params.path ?? []).join("/")}`);
if (!entry) return handleNotFound(api, path, { report: false });
// Astro: src/pages/[...path].astro
const r = await resolveEntry(Astro, client);
if (r.response) return r.response; // a redirect from the dashboard
if (r.notFound) Astro.response.status = 404;

A navigation

const nav = await client.tree("pages", { maxLevel: 2 });
// nav[i].url, nav[i].title, nav[i].children[j].url …

The related field is already resolved to EntryRefs, enough for a "read also" list:

{entry.related.map((r) => <a key={r.id} href={`/${r.uri}`}>{r.title}</a>)}

For images or intros of the related entries, fetch them in one query:

const { items: related } = await cms.entries({
  section: "news",
  id: entry.related.map((r) => r.id),
  select: ["intro", "image"],
});

Or the other direction, "news that links to this product":

const { items } = await cms.entries({ section: "news", relatedTo: product.id, limit: 3 });

Upcoming events, cheapest first

const { items } = await cms.entries({
  section: "events",
  where: {
    date: { gte: new Date().toISOString().slice(0, 10) },
    tags: "music",
    price: { lte: 20 },
    venue: { exists: true },
  },
  orderBy: "price asc",
  select: ["date", "price", "venue"],
});

Children of a structure entry

const { items: subpages } = await cms.entries({
  section: "pages",
  parentId: entry.id,
  orderBy: "structure",
  select: ["intro", "heroImage"],
});
const { items, total } = await cms.entries({
  section: ["pages", "news"],
  search: q,
  limit: 20,
  select: ["intro"],
});

search is a substring match, not full-text ranking; order the results yourself if postDate desc is not what you want.

Performance

  • Every distinct query is one cache entry, on the platform (per site + serialised query, up to an hour, purged on publish) and in your site (Next data cache tag kwisple:all / Astro memory cache, purged by the webhook). Keep query shapes stable: do not put timestamps, random values or the visitor's input in listing queries that render on every page. The first request for a new shape hits the database; the rest are cache hits.
  • where on a date with "today" changes the query once a day, which is fine; new Date().toISOString() with milliseconds would create a new cache entry per request. Truncate to the day.
  • Use select on listings and limit always; resolving assets and relations is the expensive part of a read.
  • client.site() is memoised per client; globals() and brand() are cached like any read. Calling them in the layout on every page is normal.
  • relatedTo and where add joins and a schema lookup; they are fast on a site's scale but still cost more than a plain section listing. Prefer the resolved EntryRefs on the entry when they suffice.
  • total costs a second count query; nothing to do about it, but do not request listings only to read total.
  • Field filters are validated against the schema on every request, so a typo in a handle fails loudly (400) instead of returning an empty list.
  • In Next.js keep headers()/cookies() out of cached routes, or every page renders per request and your queries run per request too (see SEO, performance and security headers).

Last updated 2026-10-30