Skip to content

Implement Geo Search

⏱ 25 minutes advanced
📜CoreGraph

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.

Create content types with latitude and longitude properties that Graph can use for geo queries.

Content type with geo properties
csharp
[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.

Use Graph’s geo-distance filter to find content within a specified radius of a reference point.

Distance-based geo query
graphql
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
  }
}

For radius-based search, compute a bounding box from the user’s coordinates and desired radius, then filter using coordinate ranges.

Bounding box calculation
javascript
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,
};
graphql
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
  }
}

After fetching results from Graph, sort them client-side by actual distance from the user’s position for accurate ordering.

Client-side distance sorting
javascript
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.

Combined geo and text search
graphql
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 }
    }
  }
}

Combine all the pieces into a React component that requests the user’s location and displays nearby stores.

Store locator integration
javascript
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>
  );
}
  • 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 limit and 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.