Reference Database
Label Groups
Label groups let a team organize its labels into named sets — and let your site query
content by group, or by a specific label within a group, straight from the
Content/ByUrl index's existing labels field.
Groups are created and managed in Strife Studio (Settings → Labels). A label belongs to at
most one group; grouping is optional. On the developer side, grouping changes what a
label's SINGLE entry in the labels array looks like — it doesn't add a second one:
| Label state | Its one entry in labels | Example |
|---|---|---|
Ungrouped | The bare name | "Blå" |
Grouped | "<group name>/<label name>" | "Färger/Blå" |
Moving a label into a group replaces its bare-name entry with the combined form — it does
not add the combined form alongside the bare one. labels is stored (projectable via
selectFields), exactly as it always was.
Grouping changes a label's index identity — plan queries accordingly. A query built against a label's bare name (
whereEquals('labels', 'Blå')) stops matching the moment that label is moved into a group; from then on the label is represented by"Färger/Blå"instead. This is safe under Strife's normal query pattern — read a label value off a document, then immediately use that value in a follow-up query — because the value is always fetched live, never hardcoded. It only matters if something needs to match a label by name independent of its current grouping (rare; no known Strife site feature does this today).
Names are used live and exactly as spelled — no slugs, no folding, no snapshot taken at
grouping time. A label named "Blå" in a group named "Färger" produces "Färger/Blå",
accents and all; if either is renamed later, the index reflects the new name on the very
next index run.
Querying by group
Every published document that carries a label currently in the group — a prefix match:
const posts = await session
.query({ indexName: 'Content/ByUrl' })
.whereEquals('locale', 'en')
.andAlso()
.whereStartsWith('labels', 'Färger/')
.all();Querying by a label that isn't grouped
By bare name — only matches while the label is ungrouped:
const posts = await session
.query({ indexName: 'Content/ByUrl' })
.whereEquals('locale', 'en')
.andAlso()
.whereEquals('labels', 'Blå')
.all();Querying by a (group, label) pair
Documents carrying a specific label in a specific group — an exact match on the single combined entry:
const news = await session
.query({ indexName: 'Content/ByUrl' })
.whereEquals('locale', 'en')
.andAlso()
.whereEquals('labels', 'Färger/Blå')
.all();The pair is a single array entry on purpose. Two separate conditions — group membership and a label match — can false-positive: a document holding some other label from the group plus the label in question from a different group satisfies both halves without ever having the pair. The combined entry cannot.
Field-level label pickers
A template field built with fields.labels() (e.g. a categories field on a list page)
resolves the same way, under its own property name — one entry per selected label, combined
form for grouped ones. Since the field is multi-valued, match it against labels with
whereIn, not whereEquals:
const page = await session
.query({ indexName: 'Content/ByUrl' })
.whereEquals('url', '/')
.selectFields(['categories'])
.firstOrNull();
// page.categories, e.g. ["Färger/Blå", "Changelog"]
const articles = await session
.query({ indexName: 'Content/ByUrl' })
.whereIn('labels', page.categories)
.selectFields(['heading'])
.all();Live preview sends the same "<group>/<label>" strings for a fields.labels() field and for
the document's own built-in labels base property, so one useState/subscribe handler serves
both the published render and the editor's preview. Two differences to know about: a
soft-deleted label keeps its bare name in the published value but is absent from the preview
value (the editor's client no longer holds it), and a related document's labels — the
snapshot you get through fields.reference()/fields.references() or the built-in related
— is still the raw label records on both paths. Studio's preview used to send raw label records
for every label field; preview-only code that read .name or .color off them should read the
string now — color was never part of the published value.
Also resolves correctly when nested inside fields.chapters() or fields.contentTemplate()
(1.9.2+) — same one-entry-per-label format, under the nested field's own property name. This
still only covers the value — a label field nested inside a chapter isn't independently
queryable via top-level selectFields/whereIn (no nested field is; it's only readable as
part of the whole parent document or chapters array).
Two things worth knowing about the nested case specifically:
- Resolution is data-driven, not schema-driven, and all-or-nothing per array. A nested label field is detected by what its stored IDs actually resolve to, not by what the schema declares the field as. A missing/dangling ID doesn't disqualify the rest of the array — but if any ID resolves to a document from some other collection, that's read as proof the field isn't a labels field after all, and the whole array falls back to standard relation projection instead of a mix of resolved names and relation objects.
- No labels selected means the property is omitted, not
[]. Unlike a root-levelfields.labels()field (which always emits[]when empty), a nested one with nothing selected has no key at all in the projected chapter object — read it asitem.tags ?? [].
Semantics worth relying on
- Grouping replaces a label's index representation, live. There's exactly one entry per label at any moment — the bare name while ungrouped, the combined form while grouped — and it always reflects the CURRENT names of the label and its group, not a snapshot from whenever it was assigned or grouped.
- A disqualified grouping falls back to the bare name, never to nothing. A soft-deleted label, an ETL-sourced label, or a label pointing at a deleted/missing group all resolve to their bare name — the label is never dropped from the index over a grouping problem.
- Deleting a group un-groups, it never un-labels. Documents keep their labels; they just fall back to their bare-name entry. This happens automatically — no republish needed.
- Regrouping is retroactive. Moving an existing label into a group — or renaming a group or label — updates the index entries of every document already carrying that label, automatically. The index tracks the label and group documents it loaded and re-indexes on their changes.
- Consistency is eventual. Index maintenance is asynchronous — a grouping or rename change is typically visible to queries within seconds, not in the same instant.
- A
/in a label or group name is not rejected. Either can produce an entry that looks like a group/label pair by shape (e.g. a label literally named"TV/Film", or a group named"News/Sport"). Rare in practice; if you hit it, there's no special handling — it's simply a string that happens to contain a slash.
Availability
Ships with the Content/ByUrl index bundled in @strifeapp/strife:
- 1.9.1+ (not 1.9.0 — that version published with a stale
@strifeapp/schemadependency that leftfields.labels()missing at runtime; see the package's own CHANGELOG): root-levellabelsand a root/field-levelfields.labels()field. - 1.9.2+: the same resolution also applies to a
fields.labels()field nested insidechapters()/contentTemplate()(see "Field-level label pickers" above) — earlier versions produce an unresolved relation-projection stub for a nested label field instead.
A team's database gets the new index the next time a project pinning the relevant version runs
strife push.
Upgrading from an earlier version: see the
package changelog
if your site already reads labels — grouping a label changes what its entry looks like,
even though the field name didn't.