Guide

Building Map-Based Property Search with Geospatial Queries

How to build map search for listings: viewport and polygon queries, spatial indexes, clustering, list and map sync, URL state, mobile and address display.

Cover: a map with a drawn search area, listing markers and one cluster.

Why map search is harder than it looks

A filter form sends one query when the user presses a button. A map sends one every time the user's hand moves. Dragging across a city changes the search area many times in a few seconds, and the answers come back in whatever order the network delivers them. At a zoomed-out view the honest answer may be tens of thousands of homes, which no browser should draw as individual pins. Meanwhile a list beside the map has to show the same homes, with the same count, at every moment.

So map search is three problems at once: a spatial query that stays fast at any zoom, a way to summarize too many results, and a front end that keeps map, list and URL consistent while requests overlap. This article covers each, for PostGIS, Elasticsearch and MongoDB. For the index design behind the Elasticsearch examples, see designing a real estate search engine with Elasticsearch; for choosing between these stores in the first place, see the tech stack guide.

What happens when the map moves

Map search: viewport, drawn area, clusters and the request flow that updates map and list togetherOn the left, a map viewport defined by its top-left and bottom-right corners. Inside it a drawn polygon contains three clusters labeled 128, 42 and 9 and five single listing markers, one of them highlighted. On the right, a result list headed 184 homes in drawn area, with one row highlighted and linked to the highlighted marker. Along the bottom, five steps: the map moves; the change is debounced and the old request canceled; the bounds, zoom and filters are written to the URL; one query returns hits and grid buckets; and one response updates both the map and the list, with stale responses dropped.MAP VIEWPORTtop left cornerbottom right cornerdrawn area (polygon filter)128429RESULT LIST184 homes in drawn areasame filters and bounds as the mapfirst page of 184cluster with countone listinghighlighted1Map movespan, zoom or draw2Debouncecancel the old request3Write the URLbounds, zoom, filters4One queryhits + grid buckets5One responsestale ones droppedrowsmarkers and clusters
A viewport with a drawn area, clusters and single markers, and the five steps from a map movement to a synchronized map and list. Counts are illustrative.

The diagram as a list:

  1. The map moves. The user pans, zooms or finishes drawing an area. The viewport is described by two corners; the drawn area by a polygon.
  2. Debounce and cancel. The front end waits for the movement to settle, and cancels the request already in flight.
  3. Write the URL. Bounds, zoom, filters and any drawn area go into the address bar, so the view can be reloaded or shared.
  4. One query. The search API applies the same filters to two outputs: a page of hits for the list, and grid buckets or points for the map.
  5. One response updates both. Markers, clusters, list rows and the total come from the same response. A response that is no longer the newest is dropped.

In the example, three clusters (128, 42 and 9 listings) and five single markers add up to the 184 homes the list reports. That arithmetic is the test of a correct implementation.

Viewport queries: the bounding box

The visible map is a rectangle defined by two corners, and "homes in view" is a bounding-box query. Two things about it change as the user moves.

Panning changes which box; zooming changes how big it is. Each zoom level out roughly quadruples the area and, in a dense market, the number of matching listings. A query that returns, for example, 60 listings at street level can return tens of thousands at metro level. The spatial filter is the same; what must change is what you ask for: individual listings when there are few, aggregated counts when there are many. Pick the mode from the result count or the zoom level on the server, not in the browser after downloading everything.

Raw bounds are unique; snapped bounds repeat. A viewport read from the map library is different for every user and every pixel of drag. We would snap the requested bounds outward to a grid (for example the edges of the map tiles that cover the view) before querying. Small pans inside the same tiles then need no new request, and the same request recurs across users, which is what lets a cache help. Draw only what is inside the true viewport.

Two edge cases deserve a test each: a box that crosses the 180th meridian, where the west longitude is greater than the east, and a view tilted or rotated by the map library, where the visible area is not an axis-aligned rectangle and you should query its enclosing box.

Spatial indexing in three data stores

Without a spatial index, a viewport query checks every listing. PostGIS puts it plainly: without indexing, a search for features requires a sequential scan of every record in the database. Each of the three stores commonly used for listings has an index for this.

Two notes from the documentation that affect design. MongoDB's $geoWithin does not require a geospatial index, so a missing index shows up as slowness, not as an error; and it does not return sorted results, so the list's sort is a separate step. In all three stores the spatial clause is one filter among several, and which condition the planner uses first depends on how selective each is, so test with realistic filter combinations and not only with the map.

An illustrative PostGIS query

Illustrative: table, column names and values are examples.

-- geom is geometry(Point, 4326): longitude is x, latitude is y
CREATE INDEX listing_search_geom_gix
  ON listing_search USING GIST (geom);

-- Listings in the viewport, optionally inside a drawn area
SELECT listing_key, list_price, bedrooms, geom
FROM   listing_search
WHERE  standard_status = 'Active'
  AND  list_price BETWEEN 400000 AND 650000
  AND  geom && ST_MakeEnvelope(-97.90, 30.18, -97.62, 30.42, 4326)
       -- xmin (west), ymin (south), xmax (east), ymax (north), SRID
  AND  ($1::geometry IS NULL OR ST_Covers($1::geometry, geom))
       -- $1: the drawn polygon in SRID 4326, or NULL when nothing is drawn
ORDER  BY listed_at DESC, listing_key
LIMIT  200;

Polygon and drawn-area search

Buyers draw shapes because the neighborhoods in their heads do not match postal codes. The query is a point-in-polygon test, shown above for PostGIS and below for Elasticsearch. The engineering is mostly in treating the polygon as untrusted input.

For named areas such as a school attendance zone, keep the boundary on the server and reference it by ID. Elasticsearch supports this directly: the geo_shape query can use a shape which has already been indexed in another index.

Clustering at low zoom

When the result is too large to draw as individual markers, group it, on the server or in the browser.

On the server: grid aggregations

The server divides the map into cells and returns one count per cell. In Elasticsearch, geotile_grid groups geo_point and geo_shape values into buckets that represent a grid, where each cell corresponds to a map tile as used by many online map sites and the precision is a zoom level between 0 and 29, inclusive. The geohash_grid aggregation does the same with geohash cells at a choice of precision between 1 and 12. Because tile cells line up with a web map's own zoom levels, we would default to the tile grid: ask for a precision a few levels above the current zoom and each map tile is divided into a fixed number of cells.

Three details make it work well:

In PostGIS, grouping by a snapped coordinate gives the same shape of answer: ST_SnapToGrid snaps all points of the input geometry to a regular grid, and a GROUP BY on the snapped point with a count returns one row per cell.

In the browser: supercluster

The supercluster library describes itself as a very fast JavaScript library for geospatial point clustering for browsers and Node. You load GeoJSON points once, then call getClusters(bbox, zoom) on every map movement with no network request; getClusterExpansionZoom returns the zoom on which the cluster expands into several children, which is the "click a cluster to zoom in" behavior. Its defaults are a cluster radius of 40 pixels and a maximum cluster zoom of 16. One constraint drives the design: once loaded, index is immutable, so a filter change means fetching the new point set and building a new index.

When each fits

Server-side gridClient-side clustering
Result sizeAny; the response size depends on the number of cells, not listingsBounded by what you are willing to download and hold in memory on a phone
Pan and zoomA request per movement (debounced, cacheable when bounds are snapped)Instant after the first load
Filter changeOne aggregation requestDownload the matching points again and rebuild the index
Cluster shapeGrid cells; stable, sometimes splits a natural groupRadius-based; follows the data more closely
Data exposureOnly counts leave the server at low zoomEvery matching coordinate is sent to the browser

A sound default for an MLS-scale site is a hybrid: grid aggregations from the server while the result is large, and individual points (clustered in the browser if they overlap) once the filtered result in view is small. For a single brokerage's own listings, where the whole set may be a few hundred points, client-side clustering alone is simpler and needs no search engine. The last row of the table returns in the section on address display below.

An illustrative Elasticsearch request

Illustrative: the cluster request for a viewport with a drawn polygon. It asks for no hits, only cells with a count and a centroid. Values are examples.

POST /listings/_search
{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        { "term":  { "standard_status": "Active" } },
        { "range": { "list_price": { "gte": 400000, "lte": 650000 } } },
        { "geo_bounding_box": {
            "location": {
              "top_left":     { "lat": 30.42, "lon": -97.90 },
              "bottom_right": { "lat": 30.18, "lon": -97.62 }
            }
        } },
        { "geo_shape": {
            "location": {
              "shape": {
                "type": "Polygon",
                "coordinates": [[
                  [-97.84, 30.38], [-97.86, 30.26], [-97.74, 30.22],
                  [-97.66, 30.30], [-97.70, 30.40], [-97.84, 30.38]
                ]]
              },
              "relation": "intersects"
            }
        } }
      ]
    }
  },
  "aggs": {
    "cells": {
      "geotile_grid": { "field": "location", "precision": 13 },
      "aggs": {
        "center": { "geo_centroid": { "field": "location" } }
      }
    }
  }
}

Keeping the list and the map in sync

The list and the map are two views of one result set, and the visible bugs are the two disagreeing: a count that does not match the list, a marker with no row. The rules we would hold to:

  • One criteria object. Filters, bounds, drawn area and sort live in one state object. The list request and the map request are both built from it, by the same function, on the server.
  • One response, or one version. Either a single request returns hits, total and clusters together, or each response carries the criteria version it answers and the UI applies only matching pairs.
  • One total. The number above the list is the total of the query, and the cluster counts sum to it. Test that sum automatically.
  • The list is a page; the map is the whole set. The list shows the first page of the sort order; the map shows every match, as markers or clusters.
  • Shared identity. Hovering a row highlights its marker and the reverse, keyed by listing ID. When the listing is inside a cluster, highlight the cluster.
  • A choice about movement. "Search as I move the map" with a visible way to turn it off. Selecting a list row should not move the map and trigger a search that removes the row.

Debouncing and cancelling requests

During a drag the map fires movement events continuously. Sending a query for each wastes the server and produces responses that arrive out of order. Two mechanisms, used together, prevent that.

Debounce: wait until movement has paused for a short interval before querying, or query on the map library's "movement ended" event. Cancel: abort the previous request when a new one starts. The browser's AbortController represents a controller object that allows you to abort one or more Web requests as and when desired, and its abort() method is able to abort fetch requests. Because a response may already be on its way when you cancel, keep a sequence number as well and ignore any response that is not the latest.

Illustrative: helper names and the 250 ms interval are examples, not recommendations for every device.

let controller = null;
let latest = 0;

const onMapSettled = debounce(async () => {
  if (controller) controller.abort();          // cancel the request in flight
  controller = new AbortController();
  const mine = ++latest;

  const state = readSearchState();             // bounds, zoom, filters, drawn area
  history.replaceState(null, '', toUrl(state));

  try {
    const res = await fetch('/api/search?' + toQuery(state), { signal: controller.signal });
    if (!res.ok) throw new Error('search failed: ' + res.status);
    const data = await res.json();
    if (mine !== latest) return;               // a newer request has started; drop this one
    renderMap(data.clusters, data.markers);
    renderList(data.hits, data.total);
  } catch (err) {
    if (err.name !== 'AbortError' && mine === latest) showSearchError(err);
  }
}, 250);

Cancelling in the browser does not by itself stop work on the server. If map queries are expensive, give them a server-side timeout too, so abandoned requests do not pile up behind a slow one.

URL state: shareable and crawlable are different goals

A buyer who sends a link expects the recipient to see the same map, so bounds, zoom, filters and drawn area go in the URL as the map moves. For continuous movement use history.replaceState, which modifies the current history entry instead of adding one, so the back button leaves the search instead of replaying every pan. Add a real history entry only for deliberate steps, such as choosing a city or applying a filter.

Crawlable is a separate question, and the answer is mostly no. Every viewport is a distinct URL, and Google describes what that produces: parameter-based faceted navigation can generate infinite URL spaces. Its guidance offers two directions. If the filtered URLs need not be indexed, prevent crawling of them, or keep the state in the fragment, since Google Search generally doesn't support URL fragments in crawling and indexing. If some must be indexable, keep them orderly: use the industry standard URL parameter separator and return a 404 when a filter combination doesn't return results.

The structure we would recommend separates the two goals:

  • Place in the path, crawlable. City, neighborhood and postal-code pages have stable paths, a server-rendered first page of listings, and links to listing detail pages. Google's URL guidance is to use the History API for content changed by JavaScript and not to use fragments to change the content of a page, which applies to these.
  • Viewport and drawn area as state, not crawled. Bounds, zoom and polygon are query parameters or a fragment on top of the place page, excluded from crawling and canonicalized to the place page. With query parameters, the server can render the matching list in the first response, so a shared link does not open on an empty map.

Indexing, canonical tags and listing status codes are covered in SEO, AEO and GEO for real estate websites.

The mobile experience

On a phone the map and the list cannot sit side by side, and a finger does two jobs.

  • Touch conflicts. A one-finger drag means "scroll the page" to the browser and "pan the map" to the map. The CSS touch-action property sets how an element's region can be manipulated by a touchscreen user; a full-screen map takes all gestures, while a map embedded in a scrolling page should not trap the scroll. Decide which you have and test both directions.
  • Bottom sheet. A common pattern is a full-screen map with the list in a sheet that rests at a peek height and expands. When the sheet covers part of the map, search the visible part, or results will hide under the sheet.
  • Drawing and targets. Freehand drawing conflicts with panning, so it needs an explicit mode with a clear way out. Markers a few pixels wide are hard to tap: cluster more aggressively on small screens and make single markers large enough to hit.
  • Low-end devices. The costs are the map library itself, the number of marker elements and the size of each response. Load the map code only when the map is shown, prefer server-side clusters so the phone never holds the full point set, and return only the fields a marker needs. The loading side is covered in optimizing listing pages for Core Web Vitals.

Address display restrictions and marker precision

A map pin is an address. The RESO Data Dictionary defines a field for the seller's choice: InternetAddressDisplayYN indicates whether or not the seller has allowed the listing address to be displayed on Internet sites. Coordinates come from the same record: the geographic latitude of some reference point on the property.

The field records the seller's instruction. What your site must do about the map when it is false is a question for your MLS's rules and your license; we do not state a rule here. As an engineering matter, plan for three consequences:

  • An exact pin discloses the address. Hiding the street line in the card while plotting the precise coordinates does not hide anything. Treat the flag as an input to the map layer, not only to the text template.
  • Coordinates in a response are public. Whatever the browser receives, a visitor can read. If a listing's location must not be shown, do not send its coordinates, including inside the point set for client-side clustering. This is where server-side clusters help: at low zoom only counts leave the server.
  • The filter itself can leak. If a withheld listing still matches by its true position, a visitor can shrink the viewport or a drawn polygon around it until the area is one lot. For such listings we would match on a coarser location, such as the postal code or a snapped grid cell, and show them in the list without a marker, or with an area-level marker if your MLS permits it.

Implement this once, in the display-policy stage described in the MLS data pipeline architecture guide: the indexer writes a display-safe location and a flag, and nothing downstream sees the exact point.

Map provider terms: check, do not assume

The map comes from a provider, under terms that differ by provider and plan and that change. We do not summarize any provider's terms. Before committing to one, read its current terms and pricing pages for the answers to these questions:

  • What is billed: map loads, tile requests, sessions, monthly users, geocoding calls? Which of those does a pan or zoom generate?
  • May geocoding results be stored, for how long, and may they be shown on a different provider's map?
  • Is the map allowed behind a login, in a native app, or on white-label sites for many client domains?
  • What attribution must be displayed, and may the logo be restyled or moved?
  • May you overlay third-party data such as MLS listings, and are there restrictions on real estate use?
  • Are static map images, printing or use in emails covered?
  • What happens at the quota: throttling, an error, or an automatic charge?

Keep the provider behind a thin interface in your own code (bounds in, markers out), so that a change of terms is a contained piece of work.

A test checklist before launch

  • Cluster counts plus single markers equal the list total, for several zooms and filter sets.
  • A fast drag across the map ends with results for the final position, never an earlier one.
  • Reloading the URL reproduces the same bounds, zoom, filters, drawn area and results.
  • The back button leaves the search page in one step after many pans.
  • A self-crossing drawn shape, a two-point shape and a swapped latitude and longitude are handled.
  • A viewport crossing the 180th meridian returns sensible results or is prevented.
  • A listing with address display off sends no exact coordinates to the browser at any zoom.
  • The spatial query uses the index with realistic filters (check the query plan or profile).
  • On a mid-range phone: the page scrolls past an embedded map, markers are tappable, and the sheet does not hide results.
  • Viewport URLs are excluded from crawling or canonicalized; place pages are server-rendered.

Peregrine's real estate SaaS case study describes a platform where Elasticsearch handles property search, filters and geo-queries, with a search indexing pipeline and geo-based filtering built as part of the work.

Frequently Asked Questions

Should map clustering happen on the server or in the browser?

It depends on how many listings match. Server-side grid aggregations return one count per cell whatever the result size; Elasticsearch’s geotile grid groups geo_point and geo_shape values into buckets that represent a grid. Client-side clustering with a library such as supercluster is instant on pan and zoom but needs every matching point in the browser, and once loaded, index is immutable. A sound default is the grid while the result is large and points once it is small.

Do I need Elasticsearch for map search, or is PostGIS enough?

PostGIS is enough for viewport and polygon queries: it uses an R-Tree index implemented on top of GiST to index spatial data, and a grouped query on snapped coordinates can produce cluster counts. A search engine becomes worthwhile when the same search also needs autocomplete, facet counts and text relevance at volume.

Can we show a pin for a listing whose address is withheld?

Check your MLS rules. The RESO field InternetAddressDisplayYN indicates whether or not the seller has allowed the listing address to be displayed on Internet sites, and an exact pin discloses the address as surely as the text does. Plan for a display-safe location and never send exact coordinates for such a listing to the browser.

Database, search engine, browser and Google documentation, the RESO Data Dictionary pages and the supercluster README were read on October 12 and 13, 2026. Queries and code are illustrative, were not executed, and are not drop-in implementations. Address display rules are set by each MLS and license; map provider terms are set by each provider. Read the current versions of both.

Building or fixing map search on MLS data? See our MLS and IDX integration service. Every project starts with a 30-minute technical discovery call with an engineer.

About the examples. Every code sample here is illustrative. The Elasticsearch query was run against Elasticsearch 8.19.1 on October 13, 2026 and was accepted. The PostGIS query and the browser snippet were read against the documentation but not executed.

Technical consultation

Building Map Search on Listing Data?

Tell us your data store, listing volume and how the map should behave on desktop and mobile. An engineer will walk through the query, clustering and sync design with you.

  • A 30-minute technical discovery call with an engineer, not a salesperson.
  • Fixed-scope projects, monthly retainers, or a combination, agreed after the call.
  • Small, well-defined tasks get a scoped estimate. We reply within 1 business day.

Prefer to pick a time? Book a call on Calendly

Tell us about your project

We'll review your details and reach out within 1 business day.

Book Strategy CallCase Studies