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 present | Meaning |
|---|---|
id, section, type | identity; type is the entry type handle (discriminate on it) |
title, slug, uri, url | uri 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 |
locale | the translation you got |
postDate, expiryDate, updatedAt | ISO strings or null; updatedAt is the later of the entry and its translation |
parentId, level | structure 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 |
| …fields | one property per field handle of the entry type, resolved (see below) |
Field values arrive resolved, not as stored ids:
- Assets (
f.assets): an array ofAsset({ 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 ofEntryRef({ id, section, type, title, slug, uri }). Only live targets are returned; the ref carries nourl, build the path fromuri(/${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 readyhref(mailto:,tel:, the entry's path, the asset URL). A link to an entry that is not live resolves tonull. - 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
| Option | Behaviour |
|---|---|
section | one handle or a list. With one section the SDK types the items as that section's entries |
type | one entry type handle or a list; combine with section for exact typing in matrix-heavy models |
id | one id or a list; an empty list returns nothing |
slug | exact match on the slug in the requested locale |
parentId | structures: direct children of that entry; null = top level (REST: parentId=null or an empty value) |
level | structures: entries at exactly this depth |
search | case-insensitive "contains" on the title and the entry's indexed text (plain text and rich text fields); not ranked |
relatedTo | entries 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
| Operator | Value | Meaning |
|---|---|---|
(bare value) or eq | string, number, boolean | equals; on a list field (multiple select, related entries) "contains this value"; on a toggle true/false |
ne | string, number, boolean | not equals (the negation of eq, so an empty field counts as "not equal") |
in | array | any of these (an empty array matches nothing) |
gt, gte, lt, lte | string or number | comparison. Numbers compare numerically; everything else compares as text, which works for ISO dates (2026-11-01 sorts before 2026-11-02) |
contains | string | case-insensitive text search in that field |
exists | boolean | true: 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
| Value | Order |
|---|---|
| (omitted) | postDate desc, then creation date descending |
postDate desc / postDate asc | by publish date |
title asc | alphabetical in the requested locale |
structure | the editor's order in a structure section (level, then position) |
updatedAt desc | most 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
limitdefaults to 100 and is clamped to 1…500. Asking for 1000 silently gives 500; page withoffsetinstead.offsetdefaults to 0.totalis the number of matching entries regardless oflimit/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
localepicks 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.
urland linkhrefs 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 …Related items on a detail page
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"],
});Site search
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. whereon 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
selecton listings andlimitalways; resolving assets and relations is the expensive part of a read. client.site()is memoised per client;globals()andbrand()are cached like any read. Calling them in the layout on every page is normal.relatedToandwhereadd joins and a schema lookup; they are fast on a site's scale but still cost more than a plain section listing. Prefer the resolvedEntryRefs on the entry when they suffice.totalcosts a second count query; nothing to do about it, but do not request listings only to readtotal.- 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).