Blog/Behind the Code/Building Map-Based Property Discovery with Google Maps
POST
July 28, 2025
LAST UPDATEDJuly 28, 2025

Building Map-Based Property Discovery with Google Maps

Case study on building an interactive map-based property discovery platform using Google Maps API, marker clustering, geospatial queries, and performant rendering for thousands of listings.

Tags

Google MapsNext.jsReal EstateFrontend
Building Map-Based Property Discovery with Google Maps
9 min read

Building Map-Based Property Discovery with Google Maps

TL;DR

I built a luxury rental property discovery platform with an interactive Google Maps interface, Supercluster-based marker clustering, synchronized map-list views, and PostGIS-powered bounding-box queries. The biggest technical challenges were keeping the map performant with thousands of listings, debouncing viewport-driven API calls to avoid hammering the backend, and optimizing property images for fast load times without sacrificing visual quality on a luxury-focused platform.

The Challenge

The client operated a luxury vacation rental business with properties across multiple regions. Their existing website had a basic list view with filters — city dropdown, price range, bedrooms. Users had to know what city they wanted before searching. There was no spatial discovery, no way to say "show me what's available along the Amalfi Coast" or "what's near this beach."

The requirements:

  • Full-screen map interface with property markers and clustering at various zoom levels
  • Synchronized list panel that updates as users pan and zoom the map
  • Filter system for price, bedrooms, amenities, property type that applies to both map and list
  • Bounding-box queries so the API only returns properties visible in the current viewport
  • Custom marker design that shows price and property type at a glance
  • High-quality image galleries with optimization for fast loading
  • Mobile-responsive map experience that works on touch devices
  • SEO-friendly property pages with server-rendered content

The platform needed to handle a catalog of several thousand properties and feel instant during map interactions. Any perceptible lag between panning the map and seeing updated markers would undermine the premium experience the client wanted.

The Architecture

Google Maps Integration with @vis.gl/react-google-maps

I used @vis.gl/react-google-maps (the official Google Maps React library) rather than the older @react-google-maps/api. The newer library provides better React 18 compatibility, proper Suspense support, and a cleaner hooks-based API:

typescript
// components/PropertyMap.tsx
import { APIProvider, Map, useMap } from "@vis.gl/react-google-maps";
 
export function PropertyMap({ properties, onBoundsChange }: PropertyMapProps) {
  return (
    <APIProvider apiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!}>
      <Map
        defaultCenter={{ lat: 40.7128, lng: -74.006 }}
        defaultZoom={12}
        mapId={process.env.NEXT_PUBLIC_MAP_ID}
        gestureHandling="greedy"
        disableDefaultUI={false}
        clickableIcons={false}
      >
        <MapContent
          properties={properties}
          onBoundsChange={onBoundsChange}
        />
      </Map>
    </APIProvider>
  );
}

The mapId reference is important — it enables Google's cloud-based map styling, which let us create a muted, luxury-feeling map theme that doesn't visually compete with the property markers.

Custom Marker Clustering with Supercluster

Google Maps has its own MarkerClusterer, but it operates on DOM elements and becomes sluggish above a few hundred markers. I used Supercluster, a fast geospatial point clustering library that works entirely in memory and produces cluster data that I render as custom markers:

typescript
// hooks/useClusteredProperties.ts
import Supercluster from "supercluster";
import { useMemo, useCallback } from "react";
 
interface PropertyPoint {
  type: "Feature";
  geometry: { type: "Point"; coordinates: [number, number] };
  properties: {
    id: string;
    price: number;
    propertyType: string;
    thumbnail: string;
  };
}
 
export function useClusteredProperties(
  properties: Property[],
  bounds: google.maps.LatLngBounds | null,
  zoom: number
) {
  const index = useMemo(() => {
    const cluster = new Supercluster<PropertyPoint["properties"]>({
      radius: 60,
      maxZoom: 16,
      minPoints: 2,
    });
 
    const points: PropertyPoint[] = properties.map((p) => ({
      type: "Feature",
      geometry: {
        type: "Point",
        coordinates: [p.longitude, p.latitude],
      },
      properties: {
        id: p.id,
        price: p.pricePerNight,
        propertyType: p.type,
        thumbnail: p.thumbnailUrl,
      },
    }));
 
    cluster.load(points);
    return cluster;
  }, [properties]);
 
  const clusters = useMemo(() => {
    if (!bounds) return [];
    const bbox: [number, number, number, number] = [
      bounds.getSouthWest().lng(),
      bounds.getSouthWest().lat(),
      bounds.getNorthEast().lng(),
      bounds.getNorthEast().lat(),
    ];
    return index.getClusters(bbox, Math.floor(zoom));
  }, [index, bounds, zoom]);
 
  return clusters;
}

Supercluster's radius parameter controls how aggressively markers are grouped. I tuned this to 60 pixels after testing — too low and the map looked cluttered at medium zoom levels, too high and users couldn't distinguish nearby properties until they zoomed in very close.

Custom Marker Components

Each marker renders differently based on whether it's a cluster or an individual property:

tsx
// components/PropertyMarker.tsx
import { AdvancedMarker } from "@vis.gl/react-google-maps";
 
export function PropertyMarker({ cluster, onClick }: MarkerProps) {
  const isCluster = cluster.properties.cluster;
 
  if (isCluster) {
    const count = cluster.properties.point_count;
    return (
      <AdvancedMarker
        position={{
          lat: cluster.geometry.coordinates[1],
          lng: cluster.geometry.coordinates[0],
        }}
        onClick={() => onClick(cluster)}
      >
        <div className="flex items-center justify-center w-10 h-10 rounded-full bg-slate-900 text-white font-semibold text-sm shadow-lg border-2 border-white">
          {count}
        </div>
      </AdvancedMarker>
    );
  }
 
  return (
    <AdvancedMarker
      position={{
        lat: cluster.geometry.coordinates[1],
        lng: cluster.geometry.coordinates[0],
      }}
      onClick={() => onClick(cluster)}
    >
      <div className="bg-white rounded-lg shadow-lg px-3 py-1.5 font-semibold text-sm border border-slate-200 hover:bg-slate-900 hover:text-white transition-colors cursor-pointer">
        ${cluster.properties.price.toLocaleString()}
        <span className="text-xs text-slate-400 ml-1">/night</span>
      </div>
    </AdvancedMarker>
  );
}

Individual property markers show the nightly price directly on the map — a pattern borrowed from Airbnb that lets users evaluate pricing without clicking each marker.

Synchronized Map-List Views

The map and the sidebar listing panel share state through a parent component. When the map viewport changes, the visible properties update in both views simultaneously:

typescript
// hooks/useMapListSync.ts
import { useCallback, useRef } from "react";
import { useDebouncedCallback } from "use-debounce";
 
export function useMapListSync(filters: PropertyFilters) {
  const [properties, setProperties] = useState<Property[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const abortControllerRef = useRef<AbortController | null>(null);
 
  const fetchProperties = useDebouncedCallback(
    async (bounds: ViewportBounds) => {
      // Cancel any in-flight request
      abortControllerRef.current?.abort();
      abortControllerRef.current = new AbortController();
 
      setIsLoading(true);
      try {
        const response = await fetch("/api/properties/search", {
          method: "POST",
          body: JSON.stringify({
            bounds: {
              north: bounds.ne.lat,
              south: bounds.sw.lat,
              east: bounds.ne.lng,
              west: bounds.sw.lng,
            },
            filters,
          }),
          signal: abortControllerRef.current.signal,
        });
        const data = await response.json();
        setProperties(data.properties);
      } catch (err) {
        if (err instanceof DOMException && err.name === "AbortError") return;
        console.error("Failed to fetch properties:", err);
      } finally {
        setIsLoading(false);
      }
    },
    300 // 300ms debounce
  );
 
  const handleBoundsChange = useCallback(
    (bounds: ViewportBounds) => {
      fetchProperties(bounds);
    },
    [fetchProperties]
  );
 
  return { properties, isLoading, handleBoundsChange };
}

The 300ms debounce is critical. Without it, every frame of a pan gesture fires an API request. With it, the API call only fires after the user stops moving the map, which dramatically reduces server load while still feeling responsive. The AbortController ensures that if the user pans again before the previous request completes, the stale request is cancelled.

Hovering over a listing in the sidebar highlights the corresponding marker on the map, and clicking a marker scrolls the sidebar to that listing:

typescript
const [hoveredPropertyId, setHoveredPropertyId] = useState<string | null>(null);
 
// In the sidebar list
<PropertyCard
  property={property}
  onMouseEnter={() => setHoveredPropertyId(property.id)}
  onMouseLeave={() => setHoveredPropertyId(null)}
  isHighlighted={hoveredPropertyId === property.id}
/>
 
// In the map markers
<PropertyMarker
  cluster={cluster}
  isHighlighted={cluster.properties.id === hoveredPropertyId}
/>

Bounding-Box Queries with PostGIS

On the backend, property locations are stored as PostGIS geography points, and bounding-box queries use ST_MakeEnvelope to find properties within the current viewport:

sql
SELECT
  p.id,
  p.title,
  p.price_per_night,
  p.property_type,
  p.bedrooms,
  ST_Y(p.location::geometry) as latitude,
  ST_X(p.location::geometry) as longitude,
  p.thumbnail_url
FROM properties p
WHERE ST_Contains(
  ST_MakeEnvelope($1, $2, $3, $4, 4326)::geography::geometry,
  p.location::geometry
)
AND p.price_per_night BETWEEN $5 AND $6
AND p.bedrooms >= $7
AND p.status = 'active'
ORDER BY p.price_per_night ASC
LIMIT 200;

The spatial index on the location column makes these queries fast:

sql
CREATE INDEX idx_properties_location ON properties USING GIST (location);

With the GIST index, bounding-box queries over the full dataset return results consistently fast, even as the catalog grows. The LIMIT 200 prevents pathological cases where a fully zoomed-out map would try to return everything.

Image Optimization

Luxury properties need high-quality images, but large images kill load times. I implemented a multi-tier image strategy:

typescript
// Thumbnail for map markers and list cards (blur-up pattern)
<Image
  src={property.thumbnailUrl}
  alt={property.title}
  width={400}
  height={300}
  placeholder="blur"
  blurDataURL={property.blurHash}
  sizes="(max-width: 768px) 100vw, 400px"
  quality={75}
/>

Images are processed at upload time into multiple sizes: a tiny blur hash for instant placeholders, a 400px thumbnail for list cards, a 800px medium for the map info window, and the full resolution for the detail page gallery. Next.js Image component handles responsive srcSet generation and lazy loading automatically, but I pre-generated the blur hashes server-side using plaiceholder to avoid layout shift during loading.

Key Decisions & Trade-offs

Supercluster (client-side clustering) vs. server-side clustering: Client-side clustering means the full dataset within the viewport is sent to the browser, and clustering happens in JavaScript. This adds bandwidth but makes zoom interactions instant since no API call is needed when changing zoom level. Server-side clustering would reduce payload size but add latency to every zoom change. For a catalog in the low thousands, client-side clustering was the right choice.

Debounced viewport queries vs. tile-based caching: An alternative approach would tile the map into grid cells and cache results per tile. This reduces API calls when users revisit the same area but adds significant caching complexity. Debounced queries are simpler and, with the spatial index, fast enough that caching wasn't necessary at this scale.

@vis.gl/react-google-maps vs. Mapbox GL JS: Mapbox offers superior customization and vector tile rendering. However, the client specifically required Google Maps for Street View integration on property detail pages and the familiarity factor for their user base. Google Maps' AdvancedMarker API with custom HTML content closed much of the customization gap.

PostGIS vs. application-level distance calculations: Computing distances in JavaScript would technically work for small datasets, but it doesn't scale and can't use spatial indexes. PostGIS makes geospatial queries a database concern where they belong, with proper indexing and optimized algorithms.

200 property limit per viewport vs. pagination: Limiting results to 200 per viewport keeps the UI responsive. If a zoomed-out view contains more than 200 properties, the clustering makes individual markers irrelevant anyway. At high zoom levels where individual markers matter, the viewport naturally contains fewer properties.

Results & Outcomes

The map-based interface fundamentally changed how users discovered properties. Instead of filtering by city name and scrolling through a list, users could explore geographically — following a coastline, checking what was near a specific landmark, or comparing prices across neighborhoods visually. The synchronized map-list view meant users who preferred list browsing still got a premium experience, with the map providing spatial context for each listing. Map interactions felt fluid even with the full property catalog loaded, thanks to Supercluster's efficient clustering and the debounced API layer. Property detail pages loaded quickly because the image optimization pipeline ensured thumbnails were small and blur-up placeholders eliminated layout shift.

What I'd Do Differently

Implement URL-based map state from the start. I added map center, zoom, and active filters to the URL query string late in the project, but it should have been there from the beginning. Users expect to share a map view by copying the URL, and back/forward navigation should restore the map state. Bolting this on later required refactoring the state management.

Use Mapbox for the base map with Google's APIs for geocoding/Street View. The Google Maps JavaScript API is expensive at scale (per-map-load pricing), and Mapbox's vector tiles render more smoothly with custom styles. Using Mapbox for the map and Google only for specific APIs like Places and Street View would have reduced costs.

Pre-compute cluster data at common zoom levels. While Supercluster is fast, pre-computing clusters server-side for the most common zoom levels (city-level, neighborhood-level) and caching them would have eliminated the brief computation pause when the property dataset first loads.

Add viewport-based prefetching. When a user is panning in a direction, the adjacent viewport area could be prefetched before they reach it. This would make the experience feel even more seamless by eliminating the brief loading state after a pan gesture completes.

FAQ

How do you handle thousands of markers on Google Maps?

We used Supercluster for client-side marker clustering, which groups nearby markers into clusters at each zoom level. The library uses a spatial index (KD-tree) to efficiently group points within a configurable pixel radius. At low zoom levels, thousands of properties collapse into a handful of numbered cluster markers. As the user zooms in, clusters split into smaller clusters or individual markers. Only the clusters and individual markers visible within the current viewport are rendered as DOM elements, keeping the rendered marker count under 200 regardless of total listing count. Supercluster handles the clustering computation in under 10ms even for datasets of 10,000+ points, so zoom transitions feel instant. We also used AdvancedMarker from Google Maps, which renders markers as real DOM elements (allowing custom HTML/CSS styling) rather than canvas-drawn images, giving us the design flexibility the luxury brand required without sacrificing performance.

PostGIS extends PostgreSQL with spatial data types and functions designed for geographic data. We store each property's location as a geography point, which represents a point on the Earth's surface using latitude and longitude in the WGS 84 coordinate system. For map-based search, when the user pans or zooms, the frontend sends the viewport's bounding box (north, south, east, west coordinates) to the API. The backend constructs a PostGIS query using ST_MakeEnvelope to create a rectangle from these coordinates and ST_Contains to find all properties whose location falls within that rectangle. A GIST spatial index on the location column makes this query efficient — the index organizes points in a tree structure optimized for spatial lookups, so the database doesn't need to check every row. For other search patterns, we use ST_DWithin for radius-based searches ("properties within 5km of this point") and ST_Contains with custom polygons for neighborhood-based searches. All of these benefit from the same spatial index.

How do you sync map movement with search results?

Map drag and zoom events fire a callback that captures the new viewport bounds (the latitude/longitude coordinates of the map's four corners). This callback is debounced by 300ms to prevent flooding the API during continuous pan gestures. When the debounce timer fires, the frontend sends a POST request to the search API with the viewport bounds and any active filters (price range, bedrooms, property type). The backend queries PostGIS for properties within those bounds, applies the filters, and returns the matching properties. On the frontend, the response updates a shared React state that both the map component and the sidebar listing panel consume. The map re-renders its markers (via Supercluster clustering), and the sidebar re-renders its property cards — both reflecting the same dataset. An AbortController cancels any in-flight request when a new one is triggered, preventing race conditions where a slow response from a previous viewport overwrites results from the current viewport. Hover interactions also sync bidirectionally: hovering a sidebar card highlights the corresponding map marker, and hovering a marker highlights the sidebar card, using a shared hoveredPropertyId state.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

Optimizing Core Web Vitals for e-Commerce
Mar 01, 202610 min read
SEO
Performance
Next.js

Optimizing Core Web Vitals for e-Commerce

Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.

Building an AI-Powered Interview Feedback System
Feb 22, 20269 min read
AI
LLM
Feedback

Building an AI-Powered Interview Feedback System

How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.

Migrating from Pages to App Router
Feb 15, 20268 min read
Next.js
Migration
Case Study

Migrating from Pages to App Router

A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.