Eight Ways In: A Content Hub Reading Reference
Hello everyone, and welcome to another blog! In this blog, we’ll explore how to consume Sitecore Content Hub data using the Content Hub WebClient SDK, HTTP raw endpoints, and search queries, with a specific focus on scenarios involving external components.
Every field you'll meet in Content Hub is one of a handful of shapes — a plain property, an option list, a taxonomy entity, a relation, a count, a search box. Each shape has one or two read methods that actually fit it. Here's how to spot which one you're looking at before you write a query.
The SDK gives you several doors into the same data — a typed query engine, a per-entity property/relation loader, an option-list reader, and a raw REST layer underneath all of it. None of them is wrong, exactly, but each is a good fit for exactly one shape of data and an awkward fit for the rest. The fastest way to pick correctly is to look at what you're trying to read before you look at the SDK's method list — the shape tells you the method, not the other way around.
Identify it in one line
Start here. Match what you're looking at to a row, then jump to that specimen for the code.
| Looks like | It's a… | Not this if… |
|---|---|---|
| A single value under "Properties," no relation icon | Direct Property | the value is itself a small fixed dropdown → Option list |
| A short fixed dropdown; stored value looks like a code, not a numeric id | Direct Option list | it has hundreds of values with their own detail page → Taxonomy |
| A large controlled vocabulary — each value is its own entity | Graph Taxonomy entity | you already hold the entity and just want its linked ids → Relation |
| A "related items" tab or panel between two entity types | Graph Relation | you don't have either entity yet, you're searching by it → still a relation, just queried instead of loaded |
| A number next to a label, no list needed | Computed Aggregate count | the number is already painted on screen by something else → DOM read |
| A search-as-you-type box expecting partial matches | Escape hatch Full-text search | an exact match is enough → typed filter instead |
| No typed SDK method covers what you need | Escape hatch Raw REST | a typed method exists → always prefer it |
| The exact value is already visible in a native widget on the page | Last resort Rendered value | re-fetching it is cheap and you already hold the entity → just re-fetch |
The eight specimens
Plain property
A single value sitting right under an entity's Properties list — text, number, boolean, or date. No relation icon next to it, no separate detail page it links to.
const config = new EntityLoadConfiguration(
CultureLoadOption.Default,
new PropertyLoadOption(['YourPropertyName']),
RelationLoadOption.None,
);
const entity = await client.entities.getAsync(entityId, config);
const value = entity.getPropertyValue('YourPropertyName', locale);Not every property accepts a locale — culture-insensitive properties throw if you pass one. Try the locale-aware call first, fall back to the plain call on catch, rather than guessing which kind a property is up front.
Option list field
A short, fixed dropdown — a status, a handful of enum-like states. The stored value reads like a code (FY2627, Approved), not a numeric entity id, and there's no detail page behind it.
const optionList = await client.optionLists.getAsync('YourOptionListIdentifier');
const values = optionList.getOptionListValues();
// [{ identifier: 'FY2627', labels: { 'en-US': 'FY26/27', ... } }, ...]Store the identifier, display the label. Labels are locale-keyed objects, not plain strings — resolve the current locale with a fallback, the same way you'd resolve a culture-sensitive property.
Taxonomy / classification entity
A large controlled vocabulary where each value is a full entity with its own id and detail page — not just a code. Could be hundreds or thousands of values, usually reached from content through a relation.
const filter = new DefinitionQueryFilter({
operator: ComparisonOperator.Equals,
name: 'M.YourTaxonomyDefinition',
});
const query = new Query({
filter, skip: pageIndex * pageSize, take: pageSize,
sorting: [{ field: 'id', fieldType: SortFieldType.System, order: QuerySortOrder.Asc }],
});
const result = await client.querying.queryAsync(query, loadConfig);There's no standard "display label" property across taxonomy definitions — different ones use different property names for it. Try a short list of common candidates (e.g. a classification-name, a label, a display-name property) and use the first that resolves. Always paginate; never take the whole vocabulary at once.
Relation between entities
A "related items" tab or panel connecting two entity types. The relation has a name, and it's directional — walked from one side (parent role) it returns a different set than walked from the other side (child role).
const config = new EntityLoadConfiguration(
CultureLoadOption.Default, PropertyLoadOption.None,
new RelationLoadOption(['YourRelationName']),
);
const entity = await client.entities.getAsync(entityId, config);
const relatedIds = entity.getRelation('YourRelationName', RelationRole.Child)?.getIds() ?? [];const filter = new RelationQueryFilter({ relation: 'YourRelationName', parentIds: [knownParentId] });
const results = await client.querying.queryAsync(new Query({ filter }), loadConfig);RelationQueryFilter searches descendants of a known parent — it can't run in reverse. If you already hold the child-side entity and just need its one parent, read the id directly off the entity's own relation instead of trying to search for it; validate with an id-equality filter afterward if it needs to slot into a larger composite filter.
Aggregate count
A number next to a label or tab — "12 related items" — where you need the count, not the records themselves.
const query = new Query({ filter: yourFilter, take: 1 });
const result = await client.querying.queryAsync(query, minimalLoadConfig);
const count = result.totalNumberOfResults; // not result.itemsThe engine still computes the full match count even with take: 1 — cheap on transfer, not on computation. Need several counts at once? Fire them in parallel and isolate failures per query (catch → return 0), so one bad filter doesn't take every other count down with it.
Full-text search
A search-as-you-type box where a partial term should match across a label or name field — not an exact value lookup.
const query = `Definition.Name=="M.YourDefinition" AND FullText=="${escapedTerm}"`;
const res = await fetch(`/api/entities/query?query=${encodeURIComponent(query)}&take=100`);Try the typed client's own Contains-style property filter first — but verify it actually returns matches before committing to it; it doesn't always. The raw endpoint's FullText== operator is often the one that reliably does partial matching. Always escape user input before it lands inside the query string.
Uncovered REST endpoint
You need something the typed client has no method for — a relation's raw link list, an endpoint you only confirmed by watching the network tab.
const res = await client.raw.getAsync(`/api/entities/${id}/relations/YourRelationName`);
// or, with no SDK client in scope at all — the session cookie already carries auth:
const res = await fetch(`/api/entities/${id}/relations/YourRelationName`);
const ids = (res.content?.children ?? []).map(link => extractIdFromHref(link.href));Treat this as the last resort, not the fast path — confirm no typed method covers it first. If you're not certain of the exact relation or endpoint name, trying a short ordered list of candidates and taking the first that returns data is a reasonable, if slightly grim, way to route around not knowing the schema for sure.
Value already rendered on screen
The exact value you need is already computed and painted by a native platform widget elsewhere on the page — re-fetching it from Content Hub would just recompute something already visible.
const el = document.querySelector(yourSelector);
// wait for the widget's own "done" event, then debounce until the text stops changing
const observer = new MutationObserver(() => scheduleSettleCheck(el));
observer.observe(el, { characterData: true, childList: true, subtree: true });Widgets often repaint in steps — an intermediate "0" before the real value — so never trust the first mutation. Debounce until it goes quiet, and tag each read cycle with a generation counter so a newer, overlapping read can discard a stale one instead of racing it into the DOM. This is coupled to markup you don't own — reach for it only when the alternative is a genuinely wasteful re-query.
If more than one fits
Roughly in order of how strong a contract each one gives you — reach down this list only as far as you have to.
Typed property or relation load. Scoped by EntityLoadConfiguration, so you only pay for what you actually asked for.
Typed query — for taxonomy browsing, relation search, or a count — over a DefinitionQueryFilter / RelationQueryFilter tree.
Option list reader for enumerated values — it's a different accessor because it's a different data shape, not a downgrade.
Raw REST, once you've confirmed no typed method covers the case — an escape hatch, used deliberately, not a default.
Reading a rendered value off the DOM — only when the alternative is recomputing something already on screen, and only with a debounce and a generation token guarding it.
You can check my other blogs too if interested. Blog Website
Comments
Post a Comment