Feature spec
AdvancedCategory
maps-location
Build with AI
6–12 hours with Lovable or FlutterFlow + custom code
Custom build
2–3 weeks custom dev
Running cost
$5–$15/mo up to 1K users
Works on
Everything it takes to ship Heatmaps — parts, prompts, and real costs.
A heatmap needs a GPU-rendered intensity layer (Mapbox GL JS heatmap layer on web, google_maps_flutter HeatmapTileProvider on mobile), server-side aggregation before any data reaches the browser (H3 hexagonal grid or PostGIS ST_SnapToGrid), and a filter panel for date range and event type. With V0 you can scaffold an analytics heatmap in 6–12 hours for $5–$15/month at 1,000 users. The critical trap is sending raw rows to the client — it freezes the browser at a few thousand points.
What a Heatmap Feature Actually Requires
A heatmap is not a map with thousands of dots — it is an intensity gradient rendered by a GPU shader over map tiles, where each visible pixel represents the aggregated density of events in that area. The rendering itself is a built-in layer type in Mapbox GL JS, Google Maps SDK, and deck.gl. What AI tools rarely generate is the aggregation step: grouping raw events into geographic cells before sending data to the browser. Without this step, a heatmap with 50,000 raw rows freezes the tab during JSON parsing alone. The real engineering work is a Supabase Edge Function that accepts viewport bounds and zoom level, aggregates events into H3 hexagons or a PostGIS grid, and returns a compact GeoJSON FeatureCollection with weight values — ideally cached for 60 seconds in Upstash Redis.
What users consider table stakes in 2026
- A colour gradient from cool (blue/green) to hot (yellow/red) communicates intensity at a glance — users must not need to read numbers to understand the pattern
- The heatmap updates without a full page reload when the user changes the date range or category filter
- A visible legend explains what the colour ramp represents (number of reports, revenue density, incident count) with min and max labels
- A toggle button lets users switch the heatmap layer on or off to see the underlying map pins
- Data is aggregated server-side — the browser never receives raw event rows, only pre-bucketed GeoJSON weight values
- Mobile pinch-zoom preserves heatmap resolution by re-fetching aggregated data at the new zoom level rather than scaling a fixed-resolution image
Anatomy of the Heatmap Feature
Six components — the heatmap renderer and aggregation pipeline are where first builds succeed or fail. The renderer is trivial; the aggregation is where the architecture matters.
Heatmap renderer
UIRenders the intensity gradient as a hardware-accelerated layer over the basemap tiles. On web: Mapbox GL JS heatmap layer type (built-in, GPU shader, configured with paint properties: heatmap-weight, heatmap-intensity, heatmap-color, heatmap-radius). Leaflet.js + the Leaflet.heat plugin is a software-rendered fallback for simpler setups. On Flutter: google_maps_flutter HeatmapTileProvider (part of the Maps SDK for Flutter, requires Maps SDK 9.0+ and a custom package).
Note: The Mapbox GL JS heatmap layer is the easiest path — it reads a GeoJSON source with a weight property per feature and handles all the gradient rendering automatically. No WebGL knowledge needed. The colour ramp (heatmap-color expression) is an interpolation array: 0 = blue, 0.3 = cyan, 0.6 = yellow, 1.0 = red.
Aggregation pipeline
BackendGroups raw events into geographic cells and computes a weight (count or sum) per cell before data leaves the server. Two approaches: (1) H3 hexagonal grid via Uber's h3-js library (JavaScript) or server-side H3 Python/C bindings — resolution tied to zoom level for multi-resolution detail; (2) PostGIS ST_SnapToGrid — rounds each lat/lng to a grid cell size and groups by that snapped coordinate. Returns (lat, lng, weight) tuples per cell.
Note: H3 is superior for zoom-adaptive heatmaps because each resolution level has a well-defined cell size (H3 res 4 = ~86km cells, res 8 = ~461m cells). As the user zooms in, switch from res 4 to res 8 for finer granularity. PostGIS ST_SnapToGrid is simpler but less elegant at multiple zoom levels.
Data API
BackendA Supabase Edge Function named heatmap-data that accepts viewport bounds (min_lat, max_lat, min_lng, max_lng), zoom level (integer), date_from, date_to, and event_type as query parameters. Returns a GeoJSON FeatureCollection with Point features, each having a weight property. Response is cached in Upstash Redis for 60 seconds with a cache key derived from the parameters.
Note: The Edge Function runs on Deno — import h3-js via esm.sh URL import or use the PostGIS aggregation approach via a Supabase RPC if you prefer to keep all logic in PostgreSQL. Never call this function directly from the client with the service role key — the anon key is sufficient since the function aggregates before returning.
Filter controls
UIA panel of controls that parameterise the data API request: a date range picker (start date, end date), a category or event type selector, and an intensity threshold slider. In React/Lovable: shadcn/ui Select component for category, a DatePicker component for the range, a Slider for threshold. In Flutter: a bottom sheet with FilterChip rows and a RangeSlider widget. Each filter change re-calls the data API and updates the Mapbox source.
Note: Debounce slider changes by 300ms to avoid firing an API call on every pixel of slider movement. Date range picker changes are coarse enough to fire immediately.
Legend component
UIA horizontal gradient bar showing the colour ramp from minimum to maximum intensity with labels at each end and a midpoint. Always visible when the heatmap layer is on. Built as a custom component; uses the same colour array as the Mapbox GL JS heatmap-color expression so the legend visually matches the map layer.
Note: The legend must show what the intensity represents in context (e.g. '0 reports' to '500+ reports' or '$0 revenue' to '$10K+ revenue') — a generic 'low to high' legend confuses users and is the most common heatmap UX failure.
Raw events table
DataA Supabase table heatmap_events storing individual events with lat, lng, event_type, weight (default 1.0), and recorded_at. This table is never sent to the client directly — the aggregation pipeline reads it and produces bucketed GeoJSON. The table should contain no personal identifiers so RLS can be permissive without privacy risk.
Note: Index on (lat, lng, recorded_at) for the viewport + date range filter. For very high event volumes (millions of rows), consider PostgreSQL table partitioning by recorded_at month to keep query scans fast.
The data model
The events table and an optional snapshot cache for pre-aggregated data. Run in the Supabase SQL editor after enabling PostGIS:
1-- Enable PostGIS (run once per project)2CREATE EXTENSION IF NOT EXISTS postgis;34create table public.heatmap_events (5 id uuid primary key default gen_random_uuid(),6 lat float8 not null,7 lng float8 not null,8 event_type text not null default 'event',9 weight float4 not null default 1.0,10 recorded_at timestamptz not null default now()11);1213alter table public.heatmap_events enable row level security;1415-- Public read on aggregated data only — never expose raw rows directly16-- The heatmap-data Edge Function uses the service role to read this table17create policy "Deny direct public access"18 on public.heatmap_events for select19 using (false);2021create index heatmap_events_geo_time_idx22 on public.heatmap_events (lat, lng, recorded_at);2324create index heatmap_events_type_idx25 on public.heatmap_events (event_type, recorded_at);2627-- PostGIS aggregation function: snap events to grid and sum weights28create or replace function public.heatmap_aggregated(29 min_lat float8,30 max_lat float8,31 min_lng float8,32 max_lng float8,33 grid_size float8 default 0.01,34 date_from timestamptz default now() - interval '30 days',35 date_to timestamptz default now(),36 p_event_type text default null37)38returns table (lat float8, lng float8, weight float8)39language sql stable40as $$41 select42 round(lat / grid_size) * grid_size as lat,43 round(lng / grid_size) * grid_size as lng,44 sum(weight) as weight45 from public.heatmap_events46 where47 lat between min_lat and max_lat48 and lng between min_lng and max_lng49 and recorded_at between date_from and date_to50 and (p_event_type is null or event_type = p_event_type)51 group by52 round(lat / grid_size) * grid_size,53 round(lng / grid_size) * grid_size54 limit 5000;55$$;Heads up: The grid_size parameter adapts to zoom level — call this function with a larger grid_size (0.1) when the user is zoomed out (city level) and smaller (0.001) when zoomed in (street level). The LIMIT 5000 prevents oversized GeoJSON responses. The RLS policy blocks direct SELECT on raw events — all client-facing data goes through the aggregation function, which the Edge Function calls with the service role key.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
Custom development enables real-time streaming heatmaps, deck.gl 3D hex layers, custom colour ramps per brand, and enterprise-grade data pipelines handling millions of events per day.
Step by step
- 1Mapbox GL JS heatmap layer + Supabase PostGIS heatmap_aggregated RPC + Upstash Redis caching in Supabase Edge Function for sub-100ms response at scale
- 2H3 hexagonal grid via h3-js with resolution dynamically mapped to zoom level (zoom 5 = H3 res 4, zoom 10 = H3 res 7, zoom 15 = H3 res 10)
- 3Supabase Realtime subscription on heatmap_events — incremental aggregation updates the GeoJSON source in near-real-time without full refetch
- 4deck.gl HexagonLayer for 3D extruded hex heatmap on executive dashboards; or ScatterplotLayer for raw point density at low event volumes
Where this path bites
- deck.gl WebGL rendering requires careful mobile performance tuning — 3D layers with thousands of hexagons can drop below 30fps on mid-range Android
- PostgreSQL table partitioning by recorded_at is needed beyond 5M events to prevent full table scans on date-filtered queries; requires DBA-level expertise
Third-party services you'll need
The heatmap renderer is free in Mapbox GL JS. Costs come from tile loads, optional Redis caching, and Supabase at scale.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Mapbox GL JS | GPU-accelerated heatmap layer rendered over map tiles | 50,000 monthly tile loads free | $0.50–$2.00 per 1,000 tile loads after free tier (approx) |
| Uber H3 (h3-js) | Hexagonal grid indexing for zoom-adaptive spatial aggregation | Open-source, free | No cost |
| Upstash Redis | Cache aggregated heatmap API responses for 60 seconds to reduce Supabase query load | 10,000 commands/day free | $0.20 per 100,000 commands after free tier (approx) |
| Supabase + PostGIS | Raw event storage, ST_SnapToGrid aggregation via RPC, RLS | Free tier: 500MB DB | Pro $25/mo (8GB DB) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Mapbox free tier covers tile loads at this scale. Supabase free tier handles event storage. No Redis caching needed — query volume is low enough for direct Supabase calls.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Blank heatmap because AI sends 50,000 raw rows as GeoJSON
Symptom: Without server-side aggregation, the first AI-generated implementation typically fetches all rows from heatmap_events and maps them to GeoJSON Features in the browser. At 50,000 rows, the JSON.parse call alone takes 3–5 seconds; Mapbox GL JS then tries to render 50,000 individual point features as a heatmap layer and either freezes the tab for 10–20 seconds or crashes it entirely. This is the most common first-build failure for heatmaps.
Fix: Implement the heatmap_aggregated() Supabase RPC or a Supabase Edge Function that runs ST_SnapToGrid or H3 bucketing before returning data. The response to the browser should never exceed 5,000 aggregated cells regardless of how many raw events exist in the database. Include 'do not send raw rows to the browser — aggregate server-side first' explicitly in your prompt.
Heatmap layer disappears at certain zoom levels
Symptom: Mapbox GL JS heatmap layers have minzoom and maxzoom paint properties that default to restrictive values. AI-generated code frequently omits these, causing the heatmap to vanish when the user zooms in past level 15 or out past level 3. Users see the basemap tiles but the intensity gradient disappears with no indication of why.
Fix: Set minzoom: 0, maxzoom: 20 on the heatmap layer configuration. Also add a moveend event listener that refetches the aggregated GeoJSON when the map moves — otherwise the heatmap represents stale data from the initial viewport even as the user pans to a new area.
FlutterFlow custom widget crashes when Maps SDK version mismatches
Symptom: HeatmapTileProvider was introduced in google_maps_flutter 2.9.0. FlutterFlow's default dependency pins an older version of the package. When you add a custom widget referencing HeatmapTileProvider with the older package, the Flutter build fails with a 'method not found' error or the pub.dev dependency resolver rejects the pubspec.yaml.
Fix: In FlutterFlow, navigate to the Custom Code panel and add google_maps_flutter: ^2.9.0 as a custom dependency, overriding the built-in version. Verify the version in the generated pubspec.yaml before running a build. If the version conflict persists, switch to a flutter_map + custom heatmap approach using the heatmap_layer package from pub.dev.
Heatmap intensity looks identical at all zoom levels
Symptom: A fixed grid size in the aggregation function produces cells of the same physical size regardless of zoom. When the user is zoomed in to street level, they expect to see fine-grained density within a few blocks — but a fixed 0.1-degree grid (about 11km) still aggregates the entire neighbourhood into one cell, making everything look uniformly intense with no detail.
Fix: Dynamically map zoom level to grid size (or H3 resolution) in the API call: zoom < 6 use grid_size=0.1, zoom 6–10 use 0.01, zoom > 10 use 0.001. Pass zoom as a parameter to the heatmap_aggregated() RPC and compute grid_size server-side. This gives users meaningful density detail as they zoom into specific areas.
RLS not set on heatmap_events exposes raw user behaviour
Symptom: AI-generated code creates the heatmap_events table without enabling Row Level Security. The anon key is used in the Edge Function, which means anyone with the anon key can execute SELECT * FROM heatmap_events and retrieve every raw event record including timing patterns that reveal user behaviour. For click heatmaps or behavioural analytics, this is a significant privacy exposure.
Fix: Enable RLS on heatmap_events and set a policy that denies direct SELECT (USING false). All client-facing data must flow through the aggregation Edge Function that uses the Supabase service role key internally and returns only aggregated, non-attributable GeoJSON — never raw individual rows. Include this architecture in your prompt: 'Edge Function reads heatmap_events with service role; returns only aggregated GeoJSON to client'.
Best practices
Always aggregate server-side before sending to the client — establish this architecture in your initial prompt before any code is generated
Tie H3 resolution (or grid_size) to the map zoom level so users see meaningful detail when zoomed in and broad patterns when zoomed out
Cache heatmap API responses for 60 seconds in Upstash Redis — heatmaps are read-heavy and often re-requested with identical parameters as users pan slightly
Limit aggregated GeoJSON responses to 5,000 cells maximum — more than this rarely adds readable density information and degrades browser rendering performance
Include a visible legend that explains what the intensity represents with concrete labels (e.g. '0–10 incidents' to '500+ incidents') — not just 'low to high'
Add a layer toggle so users can turn the heatmap off and see underlying markers or the base map — a persistent heatmap can obscure context
Never store personal identifiers in the heatmap_events table — use event_type and weight only; attribute filtering should happen via user_id joins in a separate analytics table that is never exposed to the heatmap API
When You Need Custom Development for Heatmaps
V0 and a Supabase Edge Function cover analytics heatmaps well for most products. Custom work is needed when:
- Real-time heatmap: events stream in via Supabase Realtime and the heatmap gradient should update within seconds — requires WebSocket subscription + incremental H3 cell recomputation, not full refetch
- deck.gl 3D hexagonal HexagonLayer for executive dashboards — visually impressive, requires WebGL expertise and careful mobile performance tuning to maintain 60fps
- Custom colour ramp and brand-specific gradient that must exactly match a design system or data standard (choropleth, diverging scale)
- Heatmap overlaid on custom private tile layer (indoor floorplans, private satellite imagery) where public Mapbox or Google tiles are not permitted
- More than 5 million events in heatmap_events requiring PostgreSQL table partitioning, TimescaleDB hypertables, or columnar storage to keep aggregation queries under 500ms
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
What is the difference between a heatmap and a marker map?
A marker map shows individual pins at specific coordinates — each pin is a distinct data point. A heatmap shows the density or intensity of many overlapping data points as a colour gradient — individual points are indistinguishable, but you can see where activity is concentrated. Heatmaps are better for large datasets (thousands of events) where individual markers would overlap unreadably. Marker maps are better for browsing specific items.
How do I build a heatmap with Mapbox GL JS?
Add a GeoJSON source to your Mapbox map with an id like 'heatmap-source'. Add a layer with type: 'heatmap' that reads from that source. Configure the paint properties: heatmap-weight references a 'weight' property on each feature; heatmap-color is a step or interpolate expression mapping 0–1 to your colour ramp; heatmap-radius controls the blur radius in pixels. The GeoJSON source data must come from a server-side aggregation endpoint — never a raw table query.
Can I make a heatmap in FlutterFlow without code?
No. HeatmapTileProvider in the Google Maps Flutter SDK is not exposed through FlutterFlow's visual widget panel. You need a Custom Widget code block with Dart code. It is achievable but requires Flutter development knowledge. For a heatmap feature without native mobile requirements, V0 + Next.js + Mapbox GL JS is significantly simpler.
How do I aggregate location data server-side before rendering?
Create a Supabase Edge Function that accepts the map viewport bounds and zoom level as parameters. Inside the function, call a PostgreSQL RPC that uses ST_SnapToGrid (groups raw lat/lng coordinates to a grid cell and sums their weights) or the H3 hexagonal indexing library. Return the aggregated results as a GeoJSON FeatureCollection where each feature has a weight property. The browser receives compact GeoJSON with a few hundred cells, not thousands of raw rows.
What is H3 hexagonal indexing and do I need it?
H3 is Uber's open-source library that divides the world into a hierarchical grid of hexagons at multiple resolutions. Resolution 4 has hexagons ~86km across; resolution 10 has hexagons ~0.15km across. For heatmaps, H3 is valuable because you can adapt the hexagon size to the zoom level — zoomed out shows province-scale density, zoomed in shows block-scale density. PostGIS ST_SnapToGrid is simpler but less elegant at multiple zoom levels. For a basic analytics heatmap, ST_SnapToGrid is sufficient. H3 is worth the extra complexity if you need smooth zoom transitions.
How many data points can a heatmap handle before it slows down?
With server-side aggregation: essentially unlimited — the database aggregates millions of events into a few hundred GeoJSON cells before sending anything to the browser. Without aggregation: browser performance degrades noticeably above 5,000–10,000 raw points and the tab freezes or crashes above 50,000. The architecture is the bottleneck, not the total row count.
Can users filter the heatmap by date or category?
Yes — pass filter parameters to the aggregation API. The heatmap_aggregated() RPC accepts date_from, date_to, and event_type as optional parameters. Each filter control change re-calls the API with updated params and replaces the Mapbox GeoJSON source data. Debounce slider-based filters by 300ms to avoid firing API calls on every pixel of slider movement.
Is the raw location data exposed in a heatmap API?
Only if you build it incorrectly. The safe architecture: enable Supabase RLS on heatmap_events with a policy that denies direct SELECT from public (USING false). All reads go through a Supabase Edge Function that calls the aggregation RPC with the service role key and returns only GeoJSON with aggregated weights — no row IDs, no timestamps, no user identifiers. Individual events are never reconstructible from the aggregated output.
Need this feature production-ready?
RapidDev builds heatmaps into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.