# How to Add Heatmaps to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (ui): Renders 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).
- **Aggregation pipeline** (backend): Groups 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.
- **Data API** (backend): A 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.
- **Filter controls** (ui): A 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.
- **Legend component** (ui): A 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.
- **Raw events table** (data): A 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.

## Data model

The events table and an optional snapshot cache for pre-aggregated data. Run in the Supabase SQL editor after enabling PostGIS:

```sql
-- Enable PostGIS (run once per project)
CREATE EXTENSION IF NOT EXISTS postgis;

create table public.heatmap_events (
  id uuid primary key default gen_random_uuid(),
  lat float8 not null,
  lng float8 not null,
  event_type text not null default 'event',
  weight float4 not null default 1.0,
  recorded_at timestamptz not null default now()
);

alter table public.heatmap_events enable row level security;

-- Public read on aggregated data only — never expose raw rows directly
-- The heatmap-data Edge Function uses the service role to read this table
create policy "Deny direct public access"
  on public.heatmap_events for select
  using (false);

create index heatmap_events_geo_time_idx
  on public.heatmap_events (lat, lng, recorded_at);

create index heatmap_events_type_idx
  on public.heatmap_events (event_type, recorded_at);

-- PostGIS aggregation function: snap events to grid and sum weights
create or replace function public.heatmap_aggregated(
  min_lat float8,
  max_lat float8,
  min_lng float8,
  max_lng float8,
  grid_size float8 default 0.01,
  date_from timestamptz default now() - interval '30 days',
  date_to timestamptz default now(),
  p_event_type text default null
)
returns table (lat float8, lng float8, weight float8)
language sql stable
as $$
  select
    round(lat / grid_size) * grid_size as lat,
    round(lng / grid_size) * grid_size as lng,
    sum(weight) as weight
  from public.heatmap_events
  where
    lat between min_lat and max_lat
    and lng between min_lng and max_lng
    and recorded_at between date_from and date_to
    and (p_event_type is null or event_type = p_event_type)
  group by
    round(lat / grid_size) * grid_size,
    round(lng / grid_size) * grid_size
  limit 5000;
$$;
```

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 paths

### Lovable — fit 2/10, 8–12 hours

Lovable can generate the Mapbox GL JS heatmap layer and filter UI, but the server-side aggregation step is rarely produced correctly on first generation — expect to write or heavily prompt the Edge Function manually, and all testing must happen on the published URL.

1. Add MAPBOX_PUBLIC_TOKEN to the Secrets panel in Lovable Cloud tab
2. Paste the prompt below to generate the map page, heatmap layer, filter controls, and legend component
3. Then follow up with a second prompt: 'Create a Supabase Edge Function called heatmap-data that aggregates heatmap_events with the PostGIS heatmap_aggregated() RPC and returns a GeoJSON FeatureCollection with weight properties' — the first generation will likely create a SELECT * which must be replaced
4. Publish and test on the live URL — the preview iframe shows a blank heatmap because Mapbox GL JS requires a real browser context

Starter prompt:

```
Build a heatmap analytics page using Mapbox GL JS. Load MAPBOX_PUBLIC_TOKEN from env. Full-screen map. Add a Mapbox GL JS heatmap layer (type: 'heatmap') reading from a GeoJSON source. Fetch the GeoJSON by calling a Supabase Edge Function called heatmap-data with query params: min_lat, max_lat, min_lng, max_lng, zoom, date_from, date_to, event_type. The Edge Function should call a Supabase RPC heatmap_aggregated() with those params and return a GeoJSON FeatureCollection where each feature has a weight property. Set heatmap-weight to ['get', 'weight'], heatmap-intensity to 1, heatmap-color to a blue-yellow-red gradient, heatmap-radius to 20. Refetch and update the source on every map moveend and zoomend event. Add filter controls above the map: date range (from/to date inputs), event type dropdown, and a toggle to show/hide the heatmap layer. Add a legend bar below the controls showing the colour gradient with 'Low' and 'High' labels. Handle empty dataset with a 'No events in this area' message.
```

Limitations:

- AI-generated first attempt will likely produce a SELECT * query that dumps all raw rows to the client — the Edge Function aggregation step almost always requires a follow-up prompt or manual edit
- Preview iframe renders blank; publish before testing any map rendering
- H3 zoom-adaptive resolution requires additional manual coding beyond what Lovable generates in a single session

### Flutterflow — fit 2/10, 10–14 hours

HeatmapTileProvider exists in the Google Maps Flutter SDK but FlutterFlow does not expose it through the visual builder — requires a Custom Widget code block, making this path difficult for founders without Flutter experience.

1. In FlutterFlow, add a Custom Widget code block to your page; paste Flutter code that initialises google_maps_flutter with a HeatmapTileProvider
2. Add a Supabase HTTP action that calls the heatmap-data Edge Function with current map bounds; store the GeoJSON response in page state
3. Parse the GeoJSON in a Custom Action and pass the WeightedLatLng list to the Custom Widget via state binding
4. Add filter UI widgets (date pickers, category chips) above the map and wire each to re-trigger the Supabase action on change

Limitations:

- HeatmapTileProvider requires google_maps_flutter 2.9.0 or later — FlutterFlow may pin an older version that does not include it; manually override in pubspec.yaml custom dependencies
- All heatmap logic lives in custom code blocks with no visual debugging; errors are difficult to diagnose without Dart/Flutter experience
- Not recommended for founders without Flutter development experience; V0 + web is a substantially easier path for analytics heatmaps

### V0 — fit 4/10, 6–10 hours

The best AI-tool path for heatmaps — V0 generates clean Next.js + Mapbox GL JS code and can scaffold the aggregation API route with H3 bucketing. Best suited to analytics dashboards and admin panels.

1. Paste the prompt below in V0; add NEXT_PUBLIC_MAPBOX_TOKEN in the Vars panel and Supabase env vars for the API route
2. Run the SQL schema (heatmap_events table + heatmap_aggregated RPC) in the Supabase SQL editor
3. Deploy to Vercel and verify the heatmap layer renders on the live URL — V0 sandbox may show a blank map
4. Follow up with 'adapt the H3 resolution based on the current zoom level: zoom < 6 use resolution 4, zoom 6–10 use resolution 6, zoom > 10 use resolution 8' if first generation uses a fixed grid

Starter prompt:

```
Build a Next.js heatmap analytics page using Mapbox GL JS and a Next.js API route for aggregation. Map: full viewport, style mapbox://styles/mapbox/dark-v11, NEXT_PUBLIC_MAPBOX_TOKEN from env. Add a heatmap layer (type: 'heatmap') with source id 'heatmap-source'. On map load and on moveend/zoomend: call /api/heatmap with { min_lat, max_lat, min_lng, max_lng, zoom, date_from, date_to, event_type }. The API route at /api/heatmap should query Supabase calling the heatmap_aggregated() RPC with those params (grid_size derived from zoom: zoom < 6 = 0.1, zoom 6–10 = 0.01, zoom > 10 = 0.001), and return a GeoJSON FeatureCollection with weight properties. Set heatmap-weight: ['get','weight'], heatmap-color: blue to yellow to red ramp (0=blue, 0.4=cyan, 0.6=yellow, 1=red), heatmap-radius: zoom-interpolated 10 to 30. Add filter bar above map: date range inputs, event type select, toggle switch for layer visibility. Add legend below filter bar showing the colour ramp with 'Low activity' and 'High activity' labels. Show point count from API response in the legend header. Cache API responses for 60 seconds using Next.js Route Segment Config: export const revalidate = 60.
```

Limitations:

- V0 is web-only — no native mobile heatmap output
- V0 may generate mock data for the aggregation on first attempt rather than real PostGIS queries; check that the API route actually calls Supabase
- Upstash Redis caching is more robust than Next.js revalidate for heatmaps with real-time filters; add it as a follow-up if cache invalidation on filter change is needed

### Custom — fit 5/10, 2–3 weeks

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.

1. Mapbox GL JS heatmap layer + Supabase PostGIS heatmap_aggregated RPC + Upstash Redis caching in Supabase Edge Function for sub-100ms response at scale
2. H3 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)
3. Supabase Realtime subscription on heatmap_events — incremental aggregation updates the GeoJSON source in near-real-time without full refetch
4. deck.gl HexagonLayer for 3D extruded hex heatmap on executive dashboards; or ScatterplotLayer for raw point density at low event volumes

Limitations:

- 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

## Gotchas

- **Blank heatmap because AI sends 50,000 raw rows as GeoJSON** — 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** — 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** — 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** — 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** — 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

## 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.

---

Source: https://www.rapidevelopers.com/app-features/heatmaps
© RapidDev — https://www.rapidevelopers.com/app-features/heatmaps
