
Google Maps Clustering — Webflow CMS Integration
Implementing google maps clustering on a Webflow site
Overview
- Category: Project Type · Details: Interactive map integration with CMS-driven markers and clustering
- Category: Platform · Details: Webflow CMS
- Category: Team · Details: Neal Miran (Developer)
- Category: Tech Stack · Details: Google Maps JavaScript API, @googlemaps/markerclusterer, JavaScript, Webflow CMS
- Category: Status · Details: Delivered — 2024
- Category: Delivery · Details: Side project — built to help a friend
A designer friend needed to add an interactive Google Map to a client website he was building in Webflow. The site already had location data stored in the Webflow CMS, and the goal was to render each CMS entry as a map pin, with an info window showing the listing's image, title, and description when clicked. The initial requirement grew from a basic multi-pin map into a more specific ask: implement marker clustering so that pins at the same zoom level would visually group together rather than stacking on top of each other or cluttering the map at a wider zoom.
The implementation was embedded directly into a Webflow page as a custom code block, pulling location data from the CMS using DOM elements that Webflow renders from its collection list. The Google Maps JavaScript API drove the map, and the `@googlemaps/markerclusterer` library handled clustering. Additional work covered customising the cluster icon to a branded SVG and exploring different clustering algorithms to tune how aggressively nearby pins were grouped.
Key Features
- CMS-Driven Marker Data: Location data was sourced directly from the Webflow CMS collection list rendered on the page. The map script read latitude, longitude, title, image, and description from hidden DOM elements that Webflow populated from its CMS, avoiding any direct API calls and keeping the data flow within Webflow's standard publishing model.
- Info Window on Click: Each marker opened a custom-styled info window on click, displaying the listing image, title, and description inside a compact card. The info window was shared across all markers and reused rather than instantiated per marker, keeping memory usage low.
- Marker Clustering: The `@googlemaps/markerclusterer` library grouped nearby markers into a single cluster element at lower zoom levels. Without clustering, a dense map with many pins is difficult to read and interact with — clustering resolved this by replacing overlapping markers with a count badge that expands as the user zooms in.
- Custom SVG Cluster Renderer: The default cluster appearance was replaced with a custom renderer that generated an inline SVG directly in JavaScript. The SVG used two concentric circles — a semi-transparent outer ring and a solid inner disc in the client's brand red — with the cluster count rendered in matching red text over a white inner circle. The SVG was base64-encoded and passed as a data URI to a `google.maps.Marker`, giving full visual control without external image assets.
- Custom Pin Icon: Individual markers used a custom location pin image hosted in Webflow's asset library rather than the default Google Maps teardrop, keeping the map visually consistent with the client's branding.
- Clustering Algorithm Tuning: The `@googlemaps/markerclusterer` library supports pluggable clustering algorithms. After testing the default, `GridAlgorithm` was introduced with a configurable `gridDistance` parameter. Increasing the grid distance caused the algorithm to group pins that were further apart, resulting in fewer, larger clusters at the same zoom level. This was noticeable in practice — with the default algorithm the user had to zoom out four steps before pins clustered; with a tuned `GridAlgorithm` at `gridDistance: 60`, clustering kicked in a step earlier at equivalent zoom levels.
- Side Panel Toggle: A button on the page toggled the visibility of a listing panel alongside the map, allowing users to switch between the map view and a list view of the same CMS data. The toggle was wired through a `DOMContentLoaded` listener.
Live Demo
The map plots twelve Oxford Properties complexes across three provinces. At the default zoom Ontario, Alberta, and BC each appear as a regional cluster — zoom in to watch any cluster expand into individual building pins, then click a pin to see its address.
- Complex: RAC (https://www.oxfordproperties.com/lease/complex/RAC) · City: Toronto · Province: ON · Type: Office · Buildings: 5
- Complex: MetroCentre (https://www.oxfordproperties.com/lease/complex/METROCTR) · City: Toronto · Province: ON · Type: Office / Retail · Buildings: 3
- Complex: Canada Square (https://www.oxfordproperties.com/lease/complex/CANADASQ) · City: Toronto · Province: ON · Type: Office · Buildings: 3
- Complex: WaterPark Place (https://www.oxfordproperties.com/lease/complex/WATERPRK) · City: Toronto · Province: ON · Type: Office · Buildings: 3
- Complex: Vaughan Industrial Park (https://www.oxfordproperties.com/lease/complex/ROYAL) · City: Vaughan · Province: ON · Type: Industrial · Buildings: 5
- Complex: Brampton Business Park (https://www.oxfordproperties.com/lease/complex/BRMDISCT) · City: Brampton · Province: ON · Type: Industrial · Buildings: 5
- Complex: James Snow Business Park (https://www.oxfordproperties.com/lease/complex/JamesSnowBusinessPark) · City: Milton · Province: ON · Type: Industrial · Buildings: 4
- Complex: Centennial Place (https://www.oxfordproperties.com/lease/complex/CENTENNIAL) · City: Calgary · Province: AB · Type: Office · Buildings: 2
- Complex: Oxford Airport Business Park (https://www.oxfordproperties.com/lease/complex/AIRPORTTRL) · City: Calgary · Province: AB · Type: Industrial · Buildings: 8
- Complex: Cityview Business Park (https://www.oxfordproperties.com/lease/complex/CITYVIEW) · City: Edmonton · Province: AB · Type: Industrial · Buildings: 14
- Complex: Queensborough Logistics Park (https://www.oxfordproperties.com/lease/complex/501BOYD) · City: New Westminster · Province: BC · Type: Industrial · Buildings: 3
- Complex: Riverbend Business Park (https://www.oxfordproperties.com/lease/complex/WIGGINS) · City: Burnaby · Province: BC · Type: Industrial · Buildings: 6
[Interactive live demo not embedded here — see the standalone demo link below.]
Screenshots
Technologies Used
Rendered the map and provided the Marker, InfoWindow, and Map classes used throughout. The API was loaded asynchronously with a callback to the initialisation function.
The official Google Maps marker clustering library, loaded from the unpkg CDN. Provided the MarkerClusterer class and pluggable algorithm support, including GridAlgorithm.
All map logic was written in vanilla JavaScript embedded as a custom code block in Webflow. No additional frameworks or build tools were used.
The client's chosen web builder and CMS. Location data was authored in Webflow's collection editor and rendered onto the page as HTML elements by the Webflow publishing pipeline. The map script consumed these elements directly from the DOM.
Implementation
The following code is embedded directly into the Webflow page as a custom code block. Replace `YOUR_API_KEY` with your Google Maps API key and update the `icon` URL to point to your pin asset in Webflow's asset library.
Step 1 — Add the CSS to the page's `<head>` custom code:
<style>
.projname {
font-family: Sanchezregular, sans-serif;
font-size: 14px;
line-height: 16px;
color: black;
font-weight: bold;
margin-top: 2px;
max-width: 170px;
}
.projdesc {
font-family: Sanchezregular, sans-serif;
font-size: 12px;
line-height: 15px;
color: #6d6e70;
margin-top: 2px;
max-width: 170px;
}
.projimg {
margin-top: 10px;
max-width: 180px !important;
height: 120px;
}
</style>Step 2 — Add the JavaScript and script tags to the page's `</body>` custom code:
<script>
function initializeMap() {
createMap({
latitudeClass: 'latitude',
longitudeClass: 'longitude',
imageClass: 'listing-img',
descriptionClass: 'listing-description',
titleClass: 'listing-title'
});
}
function createMap(config) {
let titles = document.getElementsByClassName(config.titleClass),
latitudes = document.getElementsByClassName(config.latitudeClass),
longitudes = document.getElementsByClassName(config.longitudeClass),
images = document.getElementsByClassName(config.imageClass),
descriptions = document.getElementsByClassName(config.descriptionClass),
markers = [],
locations = [];
for (let index = 0; index < titles.length; index++) {
let imageUrl = images[index].src;
if (latitudes[index].innerHTML !== "" && longitudes[index].innerHTML !== "") {
locations.push({
title: titles[index].innerHTML,
lat: Number(latitudes[index].innerHTML),
lng: Number(longitudes[index].innerHTML),
content: '<div class="info-box">'
+ '<img class="projimg" src="' + imageUrl + '"/>'
+ '<p class="projname">' + titles[index].innerHTML + '</p>'
+ '<p class="projdesc">' + descriptions[index].innerHTML + '</p>'
+ '</div>'
});
}
}
const map = new google.maps.Map(document.getElementById("map-wrapper"), {
zoom: 5,
center: { lat: 1.2789773525290893, lng: 103.83531939391086 },
disableDefaultUI: false,
mapId: '43d83e943216f158',
});
const infoWindow = new google.maps.InfoWindow();
locations.forEach(function(location) {
let marker = new google.maps.Marker({
position: location,
map: map,
title: location.title,
icon: 'https://YOUR_WEBFLOW_ASSET_URL/locationPin.png'
});
markers.push(marker);
attachMarkerInfoWindow(marker, location.content);
});
function attachMarkerInfoWindow(marker, content) {
marker.addListener("click", function() {
infoWindow.setContent(content);
infoWindow.open({ anchor: marker, map: map, shouldFocus: false });
});
}
// GridAlgorithm groups pins that are further apart — increase gridDistance
// to cluster more aggressively at the same zoom level.
const algorithm = new markerClusterer.GridAlgorithm({
gridDistance: 60,
});
new markerClusterer.MarkerClusterer({
map: map,
markers: markers,
algorithm: algorithm,
renderer: {
render: function({ count, position }) {
const svgMarkup = [
'<svg width="50" height="50" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">',
'<g style="opacity:.45;">',
'<circle cx="12" cy="12" r="12" style="fill:#e61e28;stroke-width:0px;"/>',
'</g>',
'<circle cx="12" cy="12" r="10.5" style="fill:#e61e28;stroke-width:0px;"/>',
'<circle cx="12" cy="12" r="8.5" style="fill:#fff;stroke-width:0px;"/>',
'<text x="12" y="16" fill="#E61E28" font-size="15" font-family="Arial" text-anchor="middle"></text>',
'</svg>'
].join('');
const svg = window.btoa(svgMarkup);
return new google.maps.Marker({
position: position,
icon: {
url: 'data:image/svg+xml;base64,' + svg,
scaledSize: new google.maps.Size(50, 50)
},
label: {
text: String(count),
color: "#E61E28",
fontSize: "15px"
}
});
}
}
});
}
document.addEventListener('DOMContentLoaded', function() {
var toggleButton = document.getElementById('toggleButton');
var listing = document.getElementById('listing');
toggleButton.addEventListener('click', function() {
listing.style.display = (listing.style.display === 'none') ? 'block' : 'none';
});
});
</script>
<!-- Marker clusterer must load before the Maps API callback fires -->
<script src="https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js"></script>
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&callback=initializeMap"></script>Challenges and Learnings
Reading CMS Data from the DOM
Webflow does not expose its CMS data as a JSON endpoint accessible to client-side scripts — data is rendered as HTML by Webflow's server when the page is published. The approach of reading from hidden DOM elements (using `getElementsByClassName` to collect latitude, longitude, title, image, and description fields rendered by a Webflow collection list) is a well-known pattern in the Webflow community but requires careful coordination between the Webflow designer's collection list structure and the class names the JavaScript expects. Any mismatch — a renamed class, a missing field, a collection list not on the page — silently produces an empty map.
Implementing the Custom Cluster Renderer
The `@googlemaps/markerclusterer` library's default renderer can be replaced by providing a `renderer` object with a `render` method that receives the cluster's `count` and `position` and returns a `google.maps.Marker`. Generating a branded SVG inside JavaScript — building the SVG markup as a template string, base64-encoding it with `window.btoa`, and wrapping it in a data URI — was the most self-contained approach, avoiding external asset hosting while allowing full control over the visual. Getting the label text to overlay the SVG correctly required coordinating the SVG's text element (which controlled placement and fill) with the `google.maps.Marker`'s `label` property (which controlled what the Google Maps layer drew on top), since the SVG text element in this implementation was intentionally left blank and the count was supplied through the marker label instead.
Tuning the Clustering Algorithm
Marker clustering behaviour is a function of two things: the algorithm and its parameters. The default algorithm did not produce the grouping density the client wanted — pins were only clustering at a zoom level where the map was already too far out to be useful for navigation. Switching to `GridAlgorithm` and exposing `gridDistance` as a tunable parameter made the clustering tighter at practical zoom levels. The tradeoff is that a higher `gridDistance` can group markers that are geographically further apart than the user might expect — worth testing against the actual data distribution of the client's locations.
Migrating Away from the Legacy Google Maps API
The existing map implementation on the site used an older version of the Google Maps JavaScript API — one that relied on the legacy `google.maps.Marker` constructor and the now-deprecated script loading pattern. Google has been progressively deprecating parts of the older Maps API in favour of the newer `google.maps.marker.AdvancedMarkerElement` class, and some of the library behaviours the original code depended on had shifted or been removed in the version the project was targeting. Adding marker clustering was not as straightforward as dropping in the `@googlemaps/markerclusterer` library on top of the existing code — the clustering library expected a consistent marker interface, and the API version mismatch surfaced as silent failures and missing features rather than clear error messages. Aligning the API version, the clustering library version, and the existing custom code required working through the changes incrementally to identify which parts of the original script broke under the newer API and which could carry over unchanged.
No Dedicated Webflow Development Environment
Working on a Webflow project as an outside contributor without familiarity with the Webflow editor added friction. Webflow's interface is tailored to designers, and the relevant custom code blocks and collection list structure are not immediately obvious without experience in the tool. Testing the JavaScript changes required republishing the page each time, since Webflow does not offer a local preview for custom code in the standard workflow. A reference project that could be cloned and inspected — in this case a community-built CMS map template — was the fastest path to understanding the existing structure before making changes.
Outcome
The map was successfully updated to include marker clustering with a custom branded cluster renderer. Pins that were previously overlapping or visually dense at common zoom levels were grouped into labelled clusters, improving readability without any loss of data — zooming in expanded each cluster back to its individual markers.
The custom SVG renderer gave the client visual control over the cluster appearance, tying it to the site's brand colour without requiring external image assets. The `GridAlgorithm` configuration gave a lever for adjusting clustering sensitivity to match the density of the client's actual location data.
The project was a practical illustration of how the `@googlemaps/markerclusterer` library's pluggable renderer and algorithm interfaces work together — the renderer controls the visual, the algorithm controls which markers get grouped — and how both can be tuned independently.