Streams, filters and search
A stream is a named listing an editor defines in Content under Configure → Streams. The stream holds the decisions a listing page would otherwise hard-code: which contracts it draws from, the order, anything excluded, how deeply each item’s references are expanded, an optional Adapter that reshapes each item, and which filters callers may use. Your site asks for the stream by its external id and supplies only paging and filter values, so an editor can change what a listing shows without a deploy.
A stream only ever returns published content. Changes to its definition, or to its Adapter’s rules, apply to the next request.
Querying a stream
Section titled “Querying a stream”GET /content/delivery/v1/streams/{externalId} returns one page:
{ "stream": { "id": "…", "externalId": "site-search" }, "items": [ { "provider": "core", "key": "…", "contract": { "id": "…", "externalId": "card", "version": 3 }, "content": { "heading": "Brewing at home", "excerpt": "…" }, "paths": [{ "siteRootNodeId": "…", "path": "/blog/brewing-at-home" }] } ], "nextCursor": "…"}Every item carries content and paths; there is no include or resolve to set, because the
stream decides. The parameters you can send are limit (1 to 100, 20 by default), cursor, locale,
ctx (see Personalization), include=referencePaths (see
Navigation, links and sitemaps), and the stream’s declared
filters. In the SDK, it is queryStream on the low-level client, which a high-level client exposes as
content.delivery:
/** * Streams: a global site-search page over an authored stream: the stream's definition owns the source set, * order, and projection; this consumer supplies only the declared `q` filter and pages by cursor. */import { createDeliveryClient, type StreamFacetResult, type StreamPage } from '@ebitex/content-sdk'
const delivery = createDeliveryClient({ apiKey: 'pk_…browser-safe…' })
export async function searchSite(query: string, cursor?: string): Promise<StreamPage> { return delivery.queryStream('site-search', { filters: { q: query }, limit: 20, cursor, })}
export async function collectAllResults(query: string): Promise<StreamPage['items']> { const items: StreamPage['items'] = [] let cursor: string | undefined do { const page = await searchSite(query, cursor) items.push(...page.items) cursor = page.nextCursor ?? undefined } while (cursor) return items}
/** * Facets: `{value, label, count}` chips for a declared filter's own * dimension, computed against every OTHER currently active filter — a facet never narrows itself, * so the target field's own value (if present in `activeFilters`) is silently excluded server-side. */export async function blogTopicChips(activeFilters: Record<string, string | readonly string[]>): Promise<StreamFacetResult> { return delivery.getStreamFacet('blog-posts', 'topic', { filters: activeFilters })}An unknown stream answers 404 unknown_stream.
Declared filters
Section titled “Declared filters”A stream accepts only the filters its editor declared, each under a key they chose. Send them as
filter.<key>=:
GET /content/delivery/v1/streams/articles?filter.topic=topics/coffee&filter.topic=topics/tea&filter.month=2026-09Repeating a key matches any of its values, and different keys must all match: the request
above is “coffee or tea, from September 2026”. A key the stream doesn’t declare answers
400 validation_failed.
| Kind | Value to send | Matches |
|---|---|---|
string |
Text | Items whose field equals the text exactly |
category |
A category path, such as topics/coffee, or a category id |
Items classified with that category or any category beneath it |
reference |
A component’s id | Items whose field references that component |
date |
A month, YYYY-MM, in UTC |
Items whose date field falls in that month |
contract |
A contract key | Items of that contract, or of any contract inheriting from it |
fullText |
One search string | Items whose text matches the search; or, declared as prefix, items where one field starts with what was typed |
string, reference and date filters read one top-level field. A field that editors can localize
or personalize never matches these filters.
A category filter can be scoped to one category group, so that a contract with two category fields
(a roast and a process, say) keeps them apart. Sending a category from another group answers
400 category_not_in_group, and an unknown category answers 400 unknown_category.
Reading one kind out of a mixed stream
Section titled “Reading one kind out of a mixed stream”A stream often sources one parent contract so that several kinds of item read as one list. A
contract filter is how a reader narrows that list back down to a kind:
GET /content/delivery/v1/streams/dogs?filter.sex=bitch&filter.colour=BlackIts values are contract keys, and it matches that contract or anything inheriting from it — so a key naming a parent matches every kind under it, and the stream’s own source contract matches everything. Unlike the other filters it needs no field, which is what makes it reliable: it reads the same contract identity every item already carries, so it cannot disagree with the data and there is no field an editor has to remember to fill in.
The stream’s sources are its scope. A key no contract has answers 400 unknown_contract; a real
contract this stream does not cover answers 400 contract_not_in_sources. Send the key, not the
contract’s id.
Because it is one stream rather than several, everything composes: the other filters still apply, the cursor still pages, and you can count by kind — which is the thing separate streams cannot do, since their counts come from different queries.
Full-text search
Section titled “Full-text search”A stream has at most one fullText filter, and it takes exactly one value. It searches every piece
of text in each published item, whatever field it is in:
| You send | Matches items containing |
|---|---|
espresso grinder |
both words |
"cold brew" |
the exact phrase |
espresso OR filter |
either word |
espresso -decaf |
espresso, but not decaf |
Words match whole and regardless of case. There is no stemming, so brew does not match brewing.
Matching the start of a word is a separate setting, described next.
Searching as someone types
Section titled “Searching as someone types”A stream can declare its fullText filter with "mode": "prefix" and a fieldPath, which suits a
type-ahead:
{ "key": "q", "type": "fullText", "mode": "prefix", "fieldPath": "name" }Every word you send must match, and the last one matches as the start of a word, so bel finds
Bella of Drakeshead and bella of dra finds it too. Anything other than a letter or a digit
separates words, and only the first six words count. A value with no letters or digits is a
400 validation_failed.
A prefix filter reads only the field you name. Searching every piece of text, as the default mode
does, would also match the descriptors that every reference carries, such as the referenced
contract’s key, so a short prefix like co would match every item that has a reference. The stream
chooses the mode; a request cannot switch it.
Facets for filter chips
Section titled “Facets for filter chips”GET /content/delivery/v1/streams/{externalId}/facets?field=<key>
lists the values one declared filter can take, with how many items match each:
{ "field": "topic", "values": [ { "value": "topics/coffee", "path": "topics/coffee", "label": "Coffee", "count": 12 }, { "value": "topics/tea", "path": "topics/tea", "label": "Tea", "count": 4 } ]}valueis what to send back as the filter.labelis a readable name forcategory,referenceandcontractvalues, andnullforstringanddatevalues, which read as they are.pathis present for categories.- A
contractfacet lists only the contracts items actually are, labelled with the contract’s name. A parent contract you may filter by therefore has no entry of its own — each item belongs to exactly one row, so counting it under its parents too would count it more than once. - Counts respect every other active filter but not the facet’s own, so a facet never narrows itself. Send the facet exactly the filters you send the stream, its own included, and choosing one topic still shows how many items every other topic has.
- Every kind except
fullTexthas facets; asking for the full-text filter, or an undeclared one, answers400 unknown_filter_field. Facets takelocalefor their labels, and noctx.
// A filtered listing over a stream: one page of items, and a set of filter chips for each facetable// filter. The `articles` stream here declares `q` (full text), `topic` (category), `author`// (reference) and `month` (date), and maps each item through an Adapter to a card.import { createDeliveryClient, pathForSite, type StreamFacetResult, type StreamPage } from '@ebitex/content-sdk'
const delivery = createDeliveryClient({ apiKey: 'pk_…browser-safe…' })
const STREAM = 'articles'const FACETS = ['topic', 'author', 'month']
/** What the visitor has chosen, keyed by the stream's filter keys. */export interface Choices { search: string selected: Record<string, readonly string[]>}
function toFilters(choices: Choices): Record<string, string | readonly string[]> { // A key with nothing selected sends nothing. Several values for one key match any of them, and // different keys must all match. const filters: Record<string, string | readonly string[]> = { ...choices.selected }
// The full-text filter takes one value: words, "quoted phrases", OR, and -word. const search = choices.search.trim() if (search) filters.q = search
return filters}
export async function loadListing(choices: Choices, cursor?: string): Promise<{ page: StreamPage; facets: StreamFacetResult[] }> { const filters = toFilters(choices)
// Every request carries the same filters, including each facet's own. The API leaves a facet's // own filter out of its counts, so choosing one topic still shows how many items the others have. const [page, facets] = await Promise.all([ delivery.queryStream(STREAM, { filters, limit: 12, cursor }), Promise.all(FACETS.map((field) => delivery.getStreamFacet(STREAM, field, { filters }))), ])
return { page, facets }}
// `value` is what to send back as the filter. Category and reference values carry a readable// `label`. A string value is readable as it is, and a date value is a month such as `2026-09`.export function chips(facet: StreamFacetResult) { return facet.values.map((entry) => ({ value: entry.value, label: entry.label ?? entry.value, count: entry.count }))}
// With an Adapter on the stream, `content` is the Adapter's output rather than the item itself, and// `item.contract` names the output contract.type Card = { heading: string; excerpt: string | null }
export function cards(page: StreamPage) { return page.items.map((item) => ({ key: item.key, card: item.content as Card | undefined, // A stream item always carries its published addresses. href: pathForSite(item.paths), }))}Paging
Section titled “Paging”nextCursor is opaque: pass it back as cursor exactly as you received it, with the same filters.
null means there are no more items. If an editor changes the stream’s order while you page, an
older cursor answers 400 invalid_cursor; start again from the first page. collectAllResults in
the first example shows the loop.
Items shaped by an Adapter
Section titled “Items shaped by an Adapter”A stream can pass every item through an Adapter, a mapping an editor defines from one contract to
another. Then content is the Adapter’s output, not the item’s own document, and contract names the
output contract. That is how one stream can list blog posts, product pages and help articles as the
same card shape, and how a listing can stay small when the items themselves are large.
Write your list renderer against the output contract. If a mapping produces warnings for an item, the
item carries them in adapterWarnings. Each item’s paths still points at the item’s own page, and
listing items that aren’t bound to any page carry an empty array. See
Typed content for generating types for the output contract.