Skip to content

Querying content

Your frontend reads content through @trokky/client. The client wraps one endpoint — GET /collections/:collection — in a chainable builder, and most of what follows is about which parts of that builder survive the trip to your storage adapter.

import { TrokkyClient } from '@trokky/client'
export const client = new TrokkyClient({
baseUrl: process.env.TROKKY_API_URL ?? 'http://localhost:3000/api',
apiToken: process.env.TROKKY_API_TOKEN ?? '',
})

baseUrl is the API mount, not the site root. Every read is authenticated: there is no anonymous read path for documents, so a build that fetches content needs a token. See Users and authentication for how to create one.

const posts = await client
.from<PostDocument>('post')
.published()
.eq('category', 'news')
.sort({ publishedAt: 'desc' })
.limit(10)
.fetch()

That produces exactly one HTTP request:

GET /api/collections/post?filter=%7B%22_status%22%3A%22published%22%2C%22category%22%3A%22news%22%7D&sort=publishedAt.desc&limit=10
Accept: application/json
Authorization: Bearer <your token>

The filter parameter is a JSON object, URL-encoded — {"_status":"published","category":"news"}. Sorting is a repeated sort parameter in field.direction form. Decoding that query string is the fastest way to find out why a query returned the wrong thing.

The server answers with an envelope:

{
"success": true,
"data": {
"documents": [ /* ... */ ],
"pagination": { "page": 1, "limit": 10, "total": 42, "pages": 5 }
}
}

fetch() returns documents. If you want the count alongside them, use fetchWithMeta(), which returns { documents, total, offset, limit, hasMore }.

Filtering is equality, whatever the builder offers

Section titled “Filtering is equality, whatever the builder offers”

The builder has eq, neq, gt, gte, lt, lte, in, notIn and where. Only equality does anything with the adapters Trokky ships.

neq and friends send an operator object — {"publishedAt":{"$gte":"2026-01-01"}}. The filesystem adapter compares each filter value to the stored value with !==, so an object never matches and you get an empty array. The Postgres adapter builds data->>'field' = $1 and passes the object through String(), so it compares against [object Object] and also matches nothing. Neither reports an error.

So filter on equality, and do ranges in your own code:

const posts = await client.from<PostDocument>('post').published().fetch()
const recent = posts.filter(p => p.publishedAt! >= '2026-01-01')

That is fine for a few hundred documents at build time and wrong for a large collection. If you need real range queries, they are not in Trokky today.

Two more limits worth knowing: the server keeps at most ten filter keys and drops the rest, and any key beginning with $ is rejected outright with a 400.

.published() and .draft() are shorthand for _status equality. They work, because they are equality. See Drafts and publishing.

Sorting works on Postgres and is ignored on the filesystem

Section titled “Sorting works on Postgres and is ignored on the filesystem”

The client sends sort=publishedAt.desc. The Postgres adapter splits that on the dot and builds an ORDER BY. The filesystem adapter does not split it: it treats publishedAt.desc as a field name, finds no such field on any document, compares undefined to undefined, and leaves the order alone.

The result is that on the filesystem adapter every collection comes back in its default order — newest first by _createdAt — no matter what you asked for. Nothing warns you.

If you are on the filesystem adapter and order matters, sort in your frontend:

const posts = await client.from<PostDocument>('post').published().fetch()
posts.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? ''))

limit and offset are honoured by both adapters. skip is an alias for offset.

const { documents, total, hasMore } = await client
.from<PostDocument>('post')
.published()
.limit(20)
.offset(40)
.fetchWithMeta()

The Postgres adapter caps limit at 1000 and defaults to 50 when you do not set one. The filesystem adapter has no default limit — it returns the whole collection, having read every JSON file in the directory first. Ask for a limit on collections you expect to grow.

const post = await client
.from<PostDocument>('post')
.eq('slug', slug)
.first()

first() sets limit=1 and returns the document or null. It is a collection query, not a fetch by id, so it works for any field you can filter on.

Fetching by id is client.getDocument('post', id), which hits GET /collections/post/:id. Its declared return type says otherwise, but at runtime it hands back the server’s payload — the document is under .document. Prefer slugs anyway: trokky restore regenerates ids for everything except singletons, so an id you hardcode today may not exist after a restore. That is written up in Traps.

For singletons, ask for the collection instead — Singletons explains why.

A reference field stores { _ref, _type }, not the document. expand asks the server to resolve it in place:

const posts = await client
.from<PostDocument>('post')
.published()
.expand('author')
.expand('tags[]')
.fetch()

That adds expand=author,tags%5B%5D to the query string. Suffix a field with [] when it holds an array of references; expand=* expands every reference field the schema declares. Expansion is one level deep — a reference inside an expanded document stays a reference.

Each referenced document is a separate read on the server, so expanding a reference across a hundred results costs a hundred reads. Fetch the referenced collection once and join in memory when the same few authors repeat across many posts. References covers the field itself.

The builder’s .search() method sends {"$text":{"$search":"..."}} as a filter. The server rejects any filter key starting with $, so this returns a 400 with Forbidden filter field: $text. Do not use it.

There is a working search, but it is not on the builder. GET /collections/:collection?search=term does a case-insensitive substring match over name, title, description, excerpt, bio, slug and _id — and it filters the page after the limit has been applied, so searching with limit=10 searches ten documents, not the collection. There is also GET /search?q=term, which scans every collection plus the media library and returns match excerpts. Both are documented in the HTTP API reference.

Reach for them through client.http.get():

const { data } = await client.http.get('/collections/post?search=harvest')

Query results are cached in memory for five minutes by default, keyed by collection and options. Set cacheMaxAge on the client to change that, or .noCache() on a single query. .fresh() also appends a _t timestamp to defeat any HTTP cache in front of the API.

The cache lives in the client instance. For a static build that is usually what you want — the same query in twenty page templates costs one request. If your build takes longer than the cache lifetime and you expect each page to see fresh content, that assumption is wrong.

count() and exists() on the builder request /collections/:collection/count, and no such route exists. That path matches the fetch-by-id route instead, so you get a 404 for a document called count. Use fetchWithMeta().total.

select() sets a select query parameter that the server never reads. Every field comes back regardless. It costs you bandwidth, not correctness.