> From the [Strife developer docs](https://strife.app/docs/reference/database/label-groups). Every page is listed in [llms.txt](https://strife.app/docs/llms.txt).

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

```ts
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:

```ts
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:

```ts
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`:

```ts
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](https://github.com/wieldyapp/wieldy/blob/master/src/sdk/js/src/packages/strife/CHANGELOG.md#190)
if your site already reads `labels` — grouping a label changes what its entry looks like,
even though the field name didn't.
