Common mistakes working with Optimizely Graph
Avoid the most common Optimizely Graph anti-patterns – oversized queries, inefficient caching, and repeated fragments – and learn what to do instead.
This page describes the most common mistakes developers make when building sites with Optimizely Graph, why each one causes problems, and what to do instead. Most of these mistakes have the same root cause – queries that are larger than they need to be – which increases parse time, request latency, and cache churn.
For the prescriptive version of these recommendations, see GraphQL best practices.
Which schema your examples use
Every mistake on this page applies to all CMS versions, but the query shapes differ. CMS 13 and CMS (SaaS) expose the native Graph schema. CMS 12 exposes the synchronization client schema.
| Concept | Native Graph schema (CMS 13, CMS SaaS) | Synchronization client schema (CMS 12) |
|---|---|---|
| System fields | _metadata { displayName types url { default } } | Top-level Name, RelativePath, ContentType, Status |
| Filter by URL | _metadata: { url: { default: { eq: $url } } } | RelativePath: { eq: $path } |
| Content type of an item | _metadata { types } | __typename, ContentType |
| Content area items | Block fields, _metadata, and _json directly on the content-area field | Wrapper items – reach the block through ContentLink { Expanded } |
| Register an interface | [ContentType] attribute | Conventions API IncludeInterface<T>() |
Each section leads with the native schema and gives the CMS 12 equivalent where the shape differs.
NoteDo not mix fields from the two schemas in the same query.
RelativePathand_metadatanever appear on the same document. To confirm which schema your instance exposes, see Explore your schema for CMS 12 and Explore your schema (SaaS) for CMS (SaaS).
Building one large query for all content types
The mistake
A common pattern is to fetch a content item by URL using the generic _Content type and then add an inline fragment (... on Type) for every content type the URL might resolve to.
# Anti-pattern – one query to rule them all
query GetContentByUrl($locale: Locales, $url: String) {
_Content(
locale: [NEUTRAL, $locale]
where: { _metadata: { url: { default: { eq: $url } } } }
) {
item {
_metadata {
displayName
}
... on StartPage {
HeroTitle
}
... on ArticlePage {
TeaserText
MainBody {
html
}
}
... on NewsPage {
PublishDate
}
# ... fragments for many more content types
}
}
}Why it is a problem
The more content types and inline fragments a query contains, the larger it becomes and the longer Optimizely Graph takes to parse it and build the correct underlying queries. A single query that tries to cover every content type is slow to execute and hard to maintain.
Do this instead
Keep queries small by querying the specific type you need. When you query ArticlePage directly, you can request exactly the fields that type exposes and the query stays small.
# Query the specific type you need
query GetArticlePage($locale: Locales, $url: String) {
ArticlePage(
locale: [NEUTRAL, $locale]
where: { _metadata: { url: { default: { eq: $url } } } }
) {
item {
_metadata {
displayName
}
MainBody {
html
}
}
}
}On CMS 12, filter on RelativePath and select top-level fields:
# CMS 12 equivalent
query GetArticleByPath($path: String!) {
ArticlePage(where: { RelativePath: { eq: $path } }) {
item {
Name
TeaserText
MainBody
}
}
}Fetching full content just to determine its type
The mistake
If you do not know which content type a URL resolves to, it is tempting to fetch everything (the large query above) and let the front end work out the type from the result.
Why it is a problem
You pay the cost of the oversized query on every request, only to use a fraction of the data.
Do this instead
Determine the type cheaply, then run a small, type-specific query. There are two common approaches:
-
Query only the type metadata and cache a URL-to-type map. Ask Graph for just the content type of a URL, cache the result, and use it to route to the correct type-specific query. On subsequent requests you already know the type and can skip this lookup.
# Look up only the type for a URL, then cache it query GetContentType($url: String) { _Content( where: { _metadata: { url: { default: { eq: $url } } } } ) { item { _metadata { types url { default } } } } }_metadata.typesreturns every type the item belongs to, including its base types. On CMS 12, query__typenameandContentTypeinstead:# CMS 12 equivalent query GetTypeForPath($path: String!) { _Content(where: { RelativePath: { eq: $path } }) { item { __typename ContentType RelativePath } } } -
Own the routing in the front end. If you control the routing layer (for example, the Next.js router in a React application), you already know which query to run for a given URL because you know exactly what to render, so no type-lookup query is needed.
Limitation
Links inside an
XhtmlStringproperty are rendered with URLs that follow the CMS content hierarchy, not your front-end routing rules. The CMS cannot yet accept third-party routing rules, so those links may not match the URLs your router expects. Until it can, the URL-to-type map described above is the more reliable option.
Overusing inline fragments for content areas
The mistake
Even when you query a specific type, a content area lets editors add any block (or page) type. To read those blocks you may fall back to an inline fragment for each possible block type, which makes the query large again.
# Anti-pattern – an inline fragment per block type in a content area
query GetArticlePage($locale: Locales, $url: String) {
ArticlePage(
locale: [NEUTRAL, $locale]
where: { _metadata: { url: { default: { eq: $url } } } }
) {
item {
_metadata {
displayName
}
MainContentArea {
... on TeaserBlock {
Heading
Text
}
... on ImageBlock {
Url
}
# ... an inline fragment for every block type
}
}
}
}Note
On CMS 12, a content area does not return the blocks themselves. It returns wrapper items that expose
ContentLink,DisplayOption, andTag, so every selection on a block – including the inline fragments above and the_jsonfield below – belongs insideContentLink { Expanded { … } }.
Why it is a problem
Content areas can hold many different block types, and blocks can be nested inside other blocks. Enumerating every type with inline fragments recreates the oversized-query problem you avoided by querying a specific type.
Do this instead
Use the _json field to return each block – including deeply nested content areas – as fully resolved JSON, then map the results to your strongly typed blocks in the front end. Your schema still describes every block type, so you keep type safety on the client.
# Return the content area as JSON in one field
query GetArticlePage($locale: Locales, $url: String) {
ArticlePage(
locale: [NEUTRAL, $locale]
where: { _metadata: { url: { default: { eq: $url } } } }
) {
item {
_metadata {
displayName
}
MainBody {
html
}
MainContentArea {
_json
}
}
}
}On CMS 12, request _json on the expanded reference:
# CMS 12 equivalent
query GetArticle($path: String!) {
ArticlePage(where: { RelativePath: { eq: $path } }) {
item {
Name
MainBody
MainContentArea {
ContentLink {
Expanded {
_json
}
}
}
}
}
}If you cannot query a specific type at all and keep falling back to inline fragments, you can request _json directly on item to return the whole content item as JSON. This is a workaround rather than a recommendation – it makes Graph behave more like a REST API and gives up the field-level control that keeps queries small – but it is preferable to enumerating every type.
Note
The
_jsonfield is locked behind a feature flag and requires an API key with read access to the target content. If_jsondoes not display in your GraphQL schema, contact your system administrator to enable the feature flag.
For more detail, see _json field and Inline fragments for content schema.
Using items when you expect a single result
items when you expect a single resultThe mistake
Using items for queries that can only return one result – for example, a lookup by URL or another unique identifier.
Why it is a problem
item and items use different caching mechanisms. When you use item, Graph assumes exactly one result matches the criteria in your where clause and uses that returned item as the cache key, so the cached result is purged only when that specific item is published again – it lives in the cache for a very long time. When you use items, Graph cannot know whether a newly published or changed content item belongs in the result, so it falls back to a much simpler mechanism and purges the cached result of every query containing items as soon as any content is published. As a result, query caches for items live only very briefly.
Do this instead
Use item whenever you expect a single result.
# Use item for single results – better cache hit ratio
query GetArticlePage($locale: Locales, $url: String) {
ArticlePage(
locale: [NEUTRAL, $locale]
where: { _metadata: { url: { default: { eq: $url } } } }
) {
item {
_metadata {
displayName
}
MainBody {
html
}
MainContentArea {
_json
}
}
}
}For more detail, see Item queries for single entities.
Repeating the same fragment across many blocks
The mistake
When many block types share a property – for example, a reference to a Digital Asset Management (DAM) item that needs special join handling – repeating the same field selection for that property inside a separate inline fragment for every block type that has it.
# Anti-pattern – repeat the reference handling for every block type
query GetArticlePage($locale: Locales, $url: String) {
ArticlePage(
locale: [NEUTRAL, $locale]
where: { _metadata: { url: { default: { eq: $url } } } }
) {
item {
MainContentArea {
... on TeaserBlock {
Asset {
Name
Url
}
}
... on HeroBlock {
Asset {
Name
Url
}
}
# ... repeated for every block that references a DAM item
}
}
}
}Why it is a problem
If 300 of your 500 blocks reference a DAM item, you end up repeating the same reference handling 300 times and the query becomes large again.
Do this instead
Define a shared interface for the property (for example, all blocks that contain a DAM reference) and implement it on every relevant block type. You then spread the interface once and the reference handling applies to all implementing types.
Graph does not add interfaces to your schema automatically. Mark the interface with the [ContentType] attribute:
[ContentType]
public interface IDamReference : IContentData
{
ContentReference Asset { get; set; }
}On CMS 12, register the interface with the Conventions API in an initializable module instead:
conventionRepo.IncludeInterface<IDamReference>();Either way, run the Optimizely Graph synchronization job afterwards. Neither the attribute nor a convention change resyncs on its own, and until your content types are synced again, ... on IDamReference refers to a type that does not exist in your schema.
You can then spread the interface once:
# Spread a shared interface once
query GetArticlePage($locale: Locales, $url: String) {
ArticlePage(
locale: [NEUTRAL, $locale]
where: { _metadata: { url: { default: { eq: $url } } } }
) {
item {
MainContentArea {
_metadata {
key
}
... on IDamReference {
Asset {
Name
Url
}
}
_json
}
}
}
}On CMS 12, spread the interface inside the expanded reference:
# CMS 12 equivalent
query GetArticle($path: String!) {
ArticlePage(where: { RelativePath: { eq: $path } }) {
item {
MainContentArea {
ContentLink {
Expanded {
... on IDamReference {
Asset {
Name
Url
}
}
}
}
}
}
}
}Note
Unlike abstract types, interface types cannot be cast to other types in the inheritance chain. If you need that, use an abstract base type instead.
C# SDK mistakes
Optimizely provides two .NET clients for Graph, and they do not share an API. Check which one your project uses before copying the examples below.
| C# SDK for CMS 13 | Optimizely Graph .NET Source SDK | |
|---|---|---|
| Used by | CMS 13 and CMS (SaaS) | CMS 12 |
| Package | Optimizely.Graph.Cms.Query | Optimizely.ContentGraph.Cms.NetCore |
| Entry point | IGraphContentClient | queryBuilder |
| Runtime filters | BuildFilter<T>() | BooleanFilter.AndFilter<T>() |
See Optimizely Graph .NET Source SDK for the CMS 12 client.
Not using GetAsContentAsync when you need resolved content
GetAsContentAsync when you need resolved contentThe mistake
Working with raw Graph results when you actually want fully resolved IContent objects. This applies to the C# SDK for CMS 13.
Why it is a problem
Raw results require extra mapping before you can use them as CMS content, which adds boilerplate and room for error.
Do this instead
Use GetAsContentAsync when possible – it returns fully resolved IContent objects that you can use directly.
// Raw Graph results – you map them yourself
var results = await _graphClient
.QueryContent<ISitePageData>()
.SearchFor("optimizely graph")
.UsingFullText()
.Fields<SitePageViewModel>(x => x.Name, x => x.TeaserText)
.GetAsync();
// Fully resolved IContent objects
var content = await _graphClient
.QueryContent<ISitePageData>()
.SearchFor("optimizely graph")
.UsingFullText()
.GetAsContentAsync();Hardcoding filters that vary at runtime
The mistake
Writing fixed inline .Where(...) conditions for filters whose values or combinations are only known at runtime.
Why it is a problem
Fixed conditions cannot express filters that change based on user input or application state, which leads to duplicated query-building code or invalid queries.
Do this instead
Use inline .Where(...) for fixed conditions, and build runtime-variable filters with a filter builder so you can combine conditions dynamically.
In the C# SDK for CMS 13, use BuildFilter<T>:
var filters = _graphClient.BuildFilter<ArticlePage>()
.And(x => x.Name.FilterStartsWith("Alloy"));
if (publishedBefore.HasValue)
{
filters = filters.And(x => x.StartPublish.LessThan(publishedBefore.Value));
}
var result = await _graphClient
.QueryContent<ArticlePage>()
.Where(filters)
.GetAsContentAsync();In the .NET Source SDK, use BooleanFilter with the AndFilter, OrFilter, and NotFilter classes:
var andFilter = BooleanFilter
.AndFilter<ArticlePage>()
.And(x => x.Name.Eq("Your page name"))
.And(x => x.Status.Eq("Published"));
var query = queryBuilder
.ForType<ArticlePage>()
.Fields(x => x.Name, x => x.MetaDescription)
.Where(andFilter)
.ToQuery()
.BuildQueries();For more detail, see Where filter.
Updated 2 days ago
