Implement Geo Search
Why geo search
Section titled “Why geo search”Store locators, regional content, and location-aware experiences need to find content based on geographic proximity. Optimizely Graph supports geo queries that filter and sort content by distance from a reference point, letting you build “find near me” features powered by your CMS content.
Step 1: Model geographic content
Section titled “Step 1: Model geographic content”Create content types with latitude and longitude properties that Graph can use for geo queries.
[ContentType(
DisplayName = "Store Location",
GUID = "c3d4e5f6-a7b8-9012-cdef-345678901234"
)]
public class StoreLocation : PageData
{
[Display(Name = "Store Name", Order = 10)]
[Required]
public virtual string StoreName { get; set; }
[Display(Name = "Address", Order = 20)]
public virtual string Address { get; set; }
[Display(Name = "City", Order = 30)]
public virtual string City { get; set; }
[Display(Name = "Latitude", Order = 40)]
public virtual double Latitude { get; set; }
[Display(Name = "Longitude", Order = 50)]
public virtual double Longitude { get; set; }
[Display(Name = "Phone", Order = 60)]
public virtual string Phone { get; set; }
} After publishing content with coordinates, sync to Graph and verify the properties appear in the GraphQL schema.
Step 2: Query by geographic distance
Section titled “Step 2: Query by geographic distance”Use Graph’s geo-distance filter to find content within a specified radius of a reference point.
query NearbyStores {
StoreLocation(
where: {
Latitude: { gte: 39.5, lte: 41.0 }
Longitude: { gte: -75.0, lte: -73.0 }
_metadata: { status: { eq: "Published" } }
}
limit: 20
) {
items {
StoreName
Address
City
Latitude
Longitude
Phone
}
total
}
} Step 3: Calculate bounding boxes
Section titled “Step 3: Calculate bounding boxes”For radius-based search, compute a bounding box from the user’s coordinates and desired radius, then filter using coordinate ranges.
function getBoundingBox(lat, lon, radiusKm) {
const EARTH_RADIUS_KM = 6371;
const latDelta = (radiusKm / EARTH_RADIUS_KM) *
(180 / Math.PI);
const lonDelta = (radiusKm / EARTH_RADIUS_KM) *
(180 / Math.PI) / Math.cos(lat * Math.PI / 180);
return {
minLat: lat - latDelta,
maxLat: lat + latDelta,
minLon: lon - lonDelta,
maxLon: lon + lonDelta,
};
}
// Find stores within 50km of New York City
const box = getBoundingBox(40.7128, -74.0060, 50);
const variables = {
minLat: box.minLat,
maxLat: box.maxLat,
minLon: box.minLon,
maxLon: box.maxLon,
}; query StoresInRadius(
$minLat: Float!
$maxLat: Float!
$minLon: Float!
$maxLon: Float!
) {
StoreLocation(
where: {
Latitude: { gte: $minLat, lte: $maxLat }
Longitude: { gte: $minLon, lte: $maxLon }
}
limit: 50
) {
items {
StoreName
Address
City
Latitude
Longitude
Phone
}
total
}
} Step 4: Sort results by distance
Section titled “Step 4: Sort results by distance”After fetching results from Graph, sort them client-side by actual distance from the user’s position for accurate ordering.
function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) *
Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function sortByDistance(stores, userLat, userLon) {
return stores
.map((store) => ({
...store,
distance: haversineDistance(
userLat, userLon,
store.Latitude, store.Longitude
),
}))
.sort((a, b) => a.distance - b.distance);
} Step 5: Combine geo search with text filters
Section titled “Step 5: Combine geo search with text filters”Location queries work alongside other Graph filters. Find stores in a specific city, by category, or matching a search term.
query FilteredStoreSearch(
$minLat: Float!
$maxLat: Float!
$minLon: Float!
$maxLon: Float!
$searchTerm: String
) {
StoreLocation(
where: {
Latitude: { gte: $minLat, lte: $maxLat }
Longitude: { gte: $minLon, lte: $maxLon }
_fulltext: { contains: $searchTerm }
_metadata: { status: { eq: "Published" } }
}
limit: 20
) {
items {
StoreName
Address
City
Latitude
Longitude
}
total
facets {
City(limit: 10) { name count }
}
}
} Step 6: Build a store locator component
Section titled “Step 6: Build a store locator component”Combine all the pieces into a React component that requests the user’s location and displays nearby stores.
import { useState, useEffect } from 'react';
import { graphFetch } from './graphClient';
const STORE_QUERY = `
query NearbyStores(
$minLat: Float!, $maxLat: Float!,
$minLon: Float!, $maxLon: Float!
) {
StoreLocation(
where: {
Latitude: { gte: $minLat, lte: $maxLat }
Longitude: { gte: $minLon, lte: $maxLon }
}
limit: 20
) {
items {
StoreName Address City
Latitude Longitude Phone
}
total
}
}
`;
export function StoreLocator({ radiusKm = 50 }) {
const [stores, setStores] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
async (pos) => {
const box = getBoundingBox(
pos.coords.latitude,
pos.coords.longitude,
radiusKm
);
const data = await graphFetch(STORE_QUERY, box);
const sorted = sortByDistance(
data.StoreLocation.items,
pos.coords.latitude,
pos.coords.longitude
);
setStores(sorted);
setLoading(false);
},
() => setLoading(false)
);
}, [radiusKm]);
if (loading) return <p>Finding nearby stores...</p>;
return (
<ul>
{stores.map((s) => (
<li key={`${s.Latitude}-${s.Longitude}`}>
<strong>{s.StoreName}</strong>
<br />{s.Address}, {s.City}
<br />{s.distance.toFixed(1)} km away
</li>
))}
</ul>
);
} Best practices
Section titled “Best practices”- Use bounding boxes for initial filtering — Bounding box queries are fast because they use simple range comparisons. Refine with haversine distance on the client for accurate radius matching.
- Limit result sets — Geographic areas can contain many results. Always set a reasonable
limitand provide pagination. - Cache geo queries — Store locator results for a given bounding box change infrequently. Use saved query templates for common radius searches.
- Request browser geolocation — Use the Geolocation API to get the user’s position, but always provide a fallback (city search or zip code input) for users who decline permission.
- Consider edge cases — Handle the international date line and polar regions where bounding box calculations need wrapping logic.