Skip to content
Strife Docs

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 stateIts one entry in labelsExample
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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-level fields.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 as item.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/schema dependency that left fields.labels() missing at runtime; see the package's own CHANGELOG): root-level labels and a root/field-level fields.labels() field.
  • 1.9.2+: the same resolution also applies to a fields.labels() field nested inside chapters()/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.