K Khan
OMVPPaaS CMS Administrator Certification
Nov 11, 2026
visibility 3
star star star star star
(0 votes)

Why your GeoPoint never reaches Optimizely Graph, and how to fix it

Geo search lets Optimizely Graph match and rank content against geographic coordinates, so applications can deliver location-aware experiences such as nearby-store lookups, regional concert venues, sports event listings, and travel content. Geo search - Graph docs

Fancy! but running the Content Graph synchronisation job in CMS, will not automatically index Location as GeoPoint, because the field arrived as two plain numbers rather than a geo field.

Lets start from a simple example OfficePage 
[ContentType(GUID = "<new-guid>")]
public class OfficePage : PageData
{
    public virtual string OfficeName { get; set; }
    public virtual double Latitude { get; set; }
    public virtual double Longitude { get; set; }
}


Optimizely Graph supports geo search: a field of type GeoPoint can be filtered by distance and ordered by distance from an origin [4]. So the obvious move is to send a lat/lon object through the CMS sync, for example with a conventions IncludeField:

public struct OfficeGeoPoint { public double lat { get; set; } public double lon { get; set; } }
conventions.ForInstancesOf<OfficePage>()
    .IncludeField(p => p.GeoLocation()); // extension method returning OfficeGeoPoint

What goes wrong

The field does reach Graph, but as a nested object with two float fields, not as a GeoPoint, so the geo operators can't be used on it [1]. We saw exactly this on a real project.

Why it happens

Graph's query side supports GeoPoint, but the CMS sync package can only produce a fixed set of field types, and GeoPoint isn't one of them [1]. A custom PropertyData, a class with lat/lon, computed fields, indexing attributes or a "lat,lon" string all end up as one of the existing types [1].

The fix: a separate content source

Keep the CMS sync for everything else, and send the coordinates to an external content source whose schema you define yourself [1][2]. Give each document a key back to its CMS item, such as the content ID, so results can be joined back to the page [1].

1. Define the content type
A full type sync is a PUT to /api/content/v3/types?id={source}. It uses Basic authentication with your Graph AppKey and Secret [2]. A source ID can be at most four characters, using only a-z and 0-9 [3].

`{
  "useTypedFieldNames": true,
  "languages": ["en"],
  "contentTypes": {
    "OfficeLocation": {
      "contentType": [],
      "properties": {
        "ContentId":  { "type": "Int" },
        "OfficeName": { "type": "String", "searchable": true },
        "Location":   { "type": "GeoPoint" }
      }
    }
  }
}`
Warning: a full sync replaces the source's schema and deletes all of its data [2]. Always send your own source ID, never the CMS source. Check that your Graph account allows another source: if you're over the limit, the call fails [2].

2. Send the documents
Data is sent as NDJSON in a POST to /api/content/v2/data?id={source}, with an action line before each document [3]. With typed field names, a geo field is named Location$$GeoPoint and holds lat and lon [4]. The metadata fields below (Status, __typename, _rbac, ContentType, Language) follow Optimizely's Graph Source SDK [5].

`{"index":{"_id":"42_en","language_routing":"en"}}
{"Status$$String":"Published","__typename":"OfficeLocation","_rbac":"r:Everyone:Read","ContentType$$String":["OfficeLocation"],"Language":{"Name$$String":"en"},"ContentId$$Int":42,"OfficeName$$String___searchable":"London","Location$$GeoPoint":{"lat":51.5072,"lon":-0.1276}}`

To remove a document, send {"delete":{"_id":"42_en","language_routing":"en"}} to the same endpoint, as the SDK does [5].

A minimal client
The SDK has no GeoPoint field type (its converter handles Boolean, DateTime, Int, Float and String, plus nested property types) [5], so a small HttpClient works:

public class OfficeLocationClient(HttpClient http, string appKey, string secret, string source)
{
    public Task PushSchemaAsync(string json) => SendAsync(HttpMethod.Put, "/api/content/v3/types", json);

    public Task UpsertAsync(OfficePage page, string lang)
    {
        var action = JsonSerializer.Serialize(new { index = new { _id = $"{page.ContentLink.ID}_{lang}", language_routing = lang } });
        var doc = JsonSerializer.Serialize(new Dictionary<string, object>
        {
            ["Status$$String"] = "Published",
            ["__typename"] = "OfficeLocation",
            ["_rbac"] = "r:Everyone:Read",
            ["ContentType$$String"] = new[] { "OfficeLocation" },
            ["Language"] = new Dictionary<string, string> { ["Name$$String"] = lang },
            ["ContentId$$Int"] = page.ContentLink.ID,
            ["OfficeName$$String___searchable"] = page.OfficeName,
            ["Location$$GeoPoint"] = new { lat = page.Latitude, lon = page.Longitude }
        });
        return SendAsync(HttpMethod.Post, "/api/content/v2/data", action + "\n" + doc + "\n");
    }

    private async Task SendAsync(HttpMethod method, string path, string body)
    {
        using var request = new HttpRequestMessage(method, $"https://cg.optimizely.com{path}?id={source}");
        request.Headers.Authorization = new("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{appKey}:{secret}")));
        request.Content = new StringContent(body, Encoding.UTF8, "application/json");
        (await http.SendAsync(request)).EnsureSuccessStatusCode();
    }
}

Query by distance
The distance filter needs an origin. Its radius defaults to 1000 and its unit to metres, and orderBy sorts nearest first unless you set order: DESC [4]:
`query NearestOffices {
  OfficeLocation(
    where: { Location: { distance: { origin: { lat: 00.0000, lon: -0.0000 }, radius: 10, unit: MI } } }
    orderBy: { Location: { origin: { lat: 00.0000, lon: -0.0000 } } }
  ) {
    items { ContentId OfficeName Location { lat lon } }
  }
}`

Summary

The CMS sync can't produce a GeoPoint, so a geo field never arrives that way [1]. Define a small external content type with a real GeoPoint [2][4], keep it in step with content events and a rebuild job, and join the results back to your pages by content ID.

References

  1. https://medium.com/@phamduckhanh2411/why-your-geopoint-field-never-shows-up-in-optimizely-graph-97a2ae01a2f5
  2. https://docs.optimizely.com/graph/docs/sync-content-types-for-external-data
  3. https://docs.optimizely.com/graph/docs/sync-content-data
  4. https://docs.optimizely.com/graph/docs/geo-search
  5. https://github.com/episerver/graph-source-sdk

 

 

 

Nov 11, 2026

Comments

error Please login to comment.
Latest blogs
Mark goes desktop: what's new in Optimizely Agent Platform, November 2026

Optimizely's November release for Mark, the Optimizely Agent Platform, is one of the most significant updates since the platform launched. Two...

Scott Reed | Nov 11, 2026

Upgrading Optimizely CMS 13 from .NET 10 to .NET 11

How to upgrade Optimizely CMS 13 to .NET 11, including a workaround for edit mode and an Azure gotcha.

Tomas Hensrud Gulla | Nov 11, 2026 |

Making Built-in CMS Categories Queryable in Optimizely Graph (CMS 13 PaaS)

With Optimizely CMS 13 PaaS, Optimizely Graph replaces Search & Navigation as the content query engine. However, if your existing CMS solution reli...

Mardy | Nov 10, 2026