Skip to main content
RapidDev - Software Development Agency
how-to-build-lovable2.5–3.5 hours

How to Build a Map Application with Lovable

Build a map application in Lovable using Leaflet with a Supabase locations table powered by PostGIS. A viewport query Edge Function uses ST_DWithin to fetch only visible markers, a geocoding Edge Function converts addresses to coordinates, and a Sheet panel shows location details — all with marker clustering for dense areas.

What you'll build

  • Interactive Leaflet map with tile layer, zoom controls, and custom marker icons by category
  • Supabase locations table with PostGIS geography column for coordinate storage and spatial queries
  • Viewport query Edge Function using ST_DWithin to return only markers within the visible map bounds
  • Geocoding Edge Function that converts user-entered addresses to latitude/longitude coordinates
  • Marker clustering using Leaflet.markercluster to handle dense location data
  • Sheet panel that slides in with location details, images, and contact info on marker click
  • Search bar with autocomplete that jumps the map to matching locations
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate15 min read2.5–3.5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a map application in Lovable using Leaflet with a Supabase locations table powered by PostGIS. A viewport query Edge Function uses ST_DWithin to fetch only visible markers, a geocoding Edge Function converts addresses to coordinates, and a Sheet panel shows location details — all with marker clustering for dense areas.

What you're building

PostGIS is a PostgreSQL extension that adds spatial data types and geographic functions. Enabling it in Supabase lets you store coordinates as a geography(POINT) column and run distance queries using SQL functions like ST_DWithin and ST_MakePoint. This is far more powerful than storing latitude and longitude as plain floats — you get indexed spatial queries that scale to millions of rows.

The viewport query Edge Function receives the map's current bounds (north, south, east, west latitudes/longitudes) and returns all locations within a bounding box plus a small buffer. As the user pans and zooms the map, the frontend calls this Edge Function and replaces the visible markers. This approach only fetches visible data instead of loading the entire locations dataset, which is essential for maps with thousands of points.

Geocoding converts a human-readable address ('123 Main St, Austin TX') to latitude/longitude coordinates. The geocoding Edge Function calls the Google Maps Geocoding API (or OpenStreetMap Nominatim for free use) and returns the coordinates, which are then used to create a new location. The service role key is needed to insert the PostGIS geography value using ST_MakePoint.

Leaflet.markercluster groups nearby markers into cluster circles showing the count. As the user zooms in, clusters split into individual markers. This makes dense location data readable at any zoom level.

Final result

A fully functional map application with PostGIS-powered spatial queries, geocoding, marker clustering, and a detail panel — built entirely in Lovable.

Tech stack

LovableMap UI and dashboard
SupabaseDatabase with PostGIS extension
Supabase Edge FunctionsViewport queries and geocoding (Deno)
Leaflet.jsInteractive map rendering
Leaflet.markerclusterMarker clustering for dense data
shadcn/uiSheet, Input, Badge, Card components

Prerequisites

  • Lovable Pro account for Edge Function generation
  • Supabase project with PostGIS extension enabled (Dashboard → Extensions → postgis)
  • Google Maps API key with Geocoding API enabled (or use Nominatim for free geocoding)
  • Google Maps API key or geocoding key saved to Cloud tab → Secrets as GEOCODING_API_KEY
  • Supabase service role key saved as SUPABASE_SERVICE_ROLE_KEY
  • Basic familiarity with latitude/longitude coordinate systems

Build steps

1

Enable PostGIS and set up the locations schema

Prompt Lovable to enable the PostGIS extension, create the locations table with a geography column, add the spatial index, and create the viewport query RPC function. The GIST spatial index is what makes ST_DWithin fast at scale.

prompt.txt
1Set up a PostGIS-powered map database in Supabase:
2
31. Enable PostGIS: CREATE EXTENSION IF NOT EXISTS postgis;
4
52. Create tables:
6 - locations: id, user_id (nullable, for user-submitted locations), name (text), description (text), category (text), address (text), phone (text, nullable), website (text, nullable), image_url (text, nullable), tags (text array), is_verified (bool default false), is_active (bool default true), coordinates geography(POINT, 4326), created_at
7 - location_reviews: id, location_id, user_id, rating (int 1-5), comment (text), created_at
8 - location_images: id, location_id, user_id, storage_path (text), caption (text), created_at
9
103. RLS:
11 - locations: public SELECT WHERE is_active = true. Authenticated users can INSERT their own rows. Users can UPDATE their own rows.
12 - location_reviews: public SELECT. Authenticated users can INSERT their own reviews.
13 - location_images: public SELECT. Authenticated users can INSERT.
14
154. Spatial index: CREATE INDEX idx_locations_coordinates ON locations USING GIST (coordinates);
16
175. Regular indexes: CREATE INDEX idx_locations_category ON locations(category, is_active);
18
196. SQL function get_locations_in_bounds(north float, south float, east float, west float, p_category text DEFAULT NULL):
20 Returns locations WHERE coordinates && ST_MakeEnvelope(west, south, east, north, 4326)::geography AND is_active = true AND (p_category IS NULL OR category = p_category)
21 Include columns: id, name, category, address, ST_X(coordinates::geometry) as lng, ST_Y(coordinates::geometry) as lat, is_verified, tags, image_url
22
237. SQL function get_nearby_locations(p_lat float, p_lng float, p_radius_meters float DEFAULT 5000, p_limit int DEFAULT 20):
24 Returns locations WHERE ST_DWithin(coordinates, ST_MakePoint(p_lng, p_lat)::geography, p_radius_meters) ORDER BY ST_Distance(coordinates, ST_MakePoint(p_lng, p_lat)::geography) ASC LIMIT p_limit

Pro tip: Ask Lovable to also seed the database with 20 sample locations in your target city by inserting rows with ST_MakePoint(longitude, latitude) values. Having test data immediately makes the map development much faster.

Expected result: PostGIS extension is enabled. The locations table has a geography column and GIST spatial index. The two RPC functions work correctly when tested in the Supabase SQL editor with sample coordinates.

2

Build the Leaflet map component

Create the core map component using Leaflet. Lovable can generate a Leaflet integration using a dynamic import to avoid SSR issues. The map component manages the Leaflet instance, handles viewport changes, and renders markers from an external locations array.

prompt.txt
1Build a MapView component at src/components/MapView.tsx.
2
3Requirements:
4- Use Leaflet (import via 'leaflet' Lovable will install it). Import CSS: import 'leaflet/dist/leaflet.css'
5- Initialize the map in a useEffect with a ref on a div container. Use OpenStreetMap tile layer: https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png with attribution
6- Default center: props.defaultCenter or [37.7749, -122.4194] (San Francisco). Default zoom: 12.
7- Accept props: locations: Location[], onBoundsChange: (bounds: Bounds) => void, onMarkerClick: (location: Location) => void, selectedLocationId: string | null
8- When bounds change (map moveend event), call onBoundsChange with { north, south, east, west } from map.getBounds()
9- Render locations as custom markers. Create a custom icon per category using L.divIcon with a colored dot:
10 - restaurant=red, park=green, hotel=blue, shop=yellow, other=gray
11- Use Leaflet.markercluster for clustering. Install and import @changey/react-leaflet-markercluster or use vanilla Leaflet MarkerClusterGroup. All markers added to the cluster group, not the map directly.
12- When selectedLocationId changes, pan the map to that location with map.panTo and open its popup
13- Show a 'Locate Me' button (crosshair icon) that calls navigator.geolocation.getCurrentPosition and pans the map to the user's coordinates
14- Clean up the Leaflet instance in the useEffect return function to prevent memory leaks

Pro tip: Fix the Leaflet default marker icon CSS issue in Lovable by adding this after the import: delete (L.Icon.Default.prototype as any)._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl }). Import the images from leaflet/dist/images/.

Expected result: The map renders with OpenStreetMap tiles, zoom controls, and a crosshair button. Markers appear for the seeded locations. Clusters form when multiple markers are close together. Clicking a marker fires onMarkerClick.

3

Build the geocoding Edge Function and location submission form

Create the geocoding Edge Function and the form for adding new locations by address. Users type an address, the Edge Function converts it to coordinates, and the location is saved with PostGIS geometry.

prompt.txt
1Build two things:
2
31. Edge Function at supabase/functions/geocode-address/index.ts:
4- Accept POST body: { address: string }
5- Call Google Maps Geocoding API: https://maps.googleapis.com/maps/api/geocode/json?address={encoded}&key={GEOCODING_API_KEY}
6- From the response, extract: formatted_address, lat and lng from geometry.location, place_id
7- Return { lat, lng, formatted_address, place_id }
8- If using Nominatim (free): call https://nominatim.openstreetmap.org/search?format=json&q={encoded}&limit=1 instead
9- Handle geocoding failures (zero_results, request_denied) with appropriate error messages
10
112. Add Location form at src/components/AddLocationForm.tsx:
12- A Dialog with a multi-step form:
13 Step 1: Address search
14 - Text Input: 'Enter address or place name'
15 - Search Button that calls geocode-address Edge Function
16 - Shows a mini-map preview (or just displays the returned formatted_address for confirmation)
17 - 'Use this location' Button advances to step 2
18 Step 2: Location details
19 - Name Input (required)
20 - Category Select: restaurant, park, hotel, shop, other
21 - Description Textarea
22 - Phone Input (optional)
23 - Website Input (optional)
24 - Tags Input (comma-separated, displays as Badges)
25 Step 3: Submit
26 - On submit, INSERT to locations table: include all fields + coordinates = ST_MakePoint(lng, lat)::geography using the Supabase RPC or raw SQL via service role Edge Function
27 - Show success Toast and close dialog

Expected result: Typing an address and clicking Search returns coordinates and a formatted address. Completing the form inserts a new location row with the PostGIS geography value. The new marker appears on the map after the next viewport update.

4

Connect viewport queries to the map and build the location detail Sheet

Wire the MapView component's onBoundsChange callback to call the get_locations_in_bounds RPC and update the markers. Build the location detail Sheet that slides in from the right when a marker is clicked.

prompt.txt
1Build the main map page at src/pages/MapPage.tsx and a LocationDetail component.
2
31. MapPage.tsx:
4- State: locations: Location[], selectedLocation: Location | null, categoryFilter: string | null, isLoading: boolean
5- When onBoundsChange fires, debounce by 300ms then call supabase.rpc('get_locations_in_bounds', { north, south, east, west, p_category: categoryFilter })
6- Set loading indicator on the map during fetch (a small spinner overlay in the top-left)
7- Category filter Buttons row above the map: All, Restaurant, Park, Hotel, Shop, Other. Each with a colored dot icon. Clicking a category updates categoryFilter and re-fetches
8- Add a search Input above the map. Typing calls supabase.from('locations').select('id, name, address, lat:ST_Y(coordinates::geometry), lng:ST_X(coordinates::geometry)').ilike('name', '%query%').limit(5) for autocomplete. Selecting a result sets selectedLocation and triggers map pan.
9- Pass selectedLocation.id as selectedLocationId to MapView
10
112. LocationDetail Sheet:
12- Props: location: Location | null, onClose: () => void
13- Sheet opens from the right (side='right', width 400px)
14- Header: location name, category Badge, is_verified Badge (checkmark icon)
15- If image_url exists, show full-width image at top
16- Body sections:
17 - Address with a 'Get Directions' link (https://maps.google.com/?q={lat},{lng})
18 - Phone and Website (if present)
19 - Description
20 - Tags as Badges
21 - Average rating (from location_reviews aggregate) and review count
22 - Recent reviews list: last 3 reviews with star rating and comment
23 - 'Add Review' Button that opens a nested Dialog

Pro tip: Debounce the onBoundsChange handler with a 300ms delay to avoid firing a Supabase query on every pixel of panning. Use a ref to store the timeout ID and clear it on each new event.

Expected result: Panning the map triggers new Supabase RPC calls and updates visible markers. Clicking a marker opens the LocationDetail Sheet. Category filters re-fetch markers for the visible category only. The search bar finds and centers the map on matching locations.

Complete code

supabase/functions/geocode-address/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2
3const corsHeaders = {
4 'Access-Control-Allow-Origin': '*',
5 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
6 'Content-Type': 'application/json',
7}
8
9interface GeocodeResult {
10 lat: number
11 lng: number
12 formatted_address: string
13 place_id?: string
14}
15
16serve(async (req: Request) => {
17 if (req.method === 'OPTIONS') {
18 return new Response('ok', { headers: corsHeaders })
19 }
20
21 try {
22 const { address } = await req.json()
23 if (!address?.trim()) {
24 return new Response(
25 JSON.stringify({ error: 'Address is required' }),
26 { status: 400, headers: corsHeaders }
27 )
28 }
29
30 const apiKey = Deno.env.get('GEOCODING_API_KEY')
31 let result: GeocodeResult
32
33 if (apiKey) {
34 // Google Maps Geocoding API
35 const encoded = encodeURIComponent(address)
36 const res = await fetch(
37 `https://maps.googleapis.com/maps/api/geocode/json?address=${encoded}&key=${apiKey}`
38 )
39 const data = await res.json()
40
41 if (data.status !== 'OK' || !data.results?.length) {
42 return new Response(
43 JSON.stringify({ error: `Geocoding failed: ${data.status}` }),
44 { status: 422, headers: corsHeaders }
45 )
46 }
47
48 const first = data.results[0]
49 result = {
50 lat: first.geometry.location.lat,
51 lng: first.geometry.location.lng,
52 formatted_address: first.formatted_address,
53 place_id: first.place_id,
54 }
55 } else {
56 // Nominatim fallback (free, no key required)
57 const encoded = encodeURIComponent(address)
58 const res = await fetch(
59 `https://nominatim.openstreetmap.org/search?format=json&q=${encoded}&limit=1`,
60 { headers: { 'User-Agent': 'MapApplication/1.0' } }
61 )
62 const data = await res.json()
63
64 if (!data?.length) {
65 return new Response(
66 JSON.stringify({ error: 'Address not found' }),
67 { status: 422, headers: corsHeaders }
68 )
69 }
70
71 result = {
72 lat: parseFloat(data[0].lat),
73 lng: parseFloat(data[0].lon),
74 formatted_address: data[0].display_name,
75 }
76 }
77
78 return new Response(JSON.stringify(result), { headers: corsHeaders })
79 } catch (err) {
80 const message = err instanceof Error ? err.message : 'Unknown error'
81 return new Response(
82 JSON.stringify({ error: message }),
83 { status: 500, headers: corsHeaders }
84 )
85 }
86})

Customization ideas

Heatmap layer for density visualization

Add a heatmap toggle that switches from individual markers to a density heatmap using Leaflet.heat plugin. The heatmap weights each location by review count or a custom metric. Toggle between markers and heatmap views with a button in the map controls bar.

Driving directions integration

Add a 'Get Directions' mode where users set a starting location (using geolocation or address input) and a destination. Call the Google Maps Directions API via an Edge Function, draw the route polyline on the Leaflet map using L.polyline, and show turn-by-turn instructions in a sidebar panel.

Offline map tiles caching

Add a service worker that caches Leaflet tile images for the last viewed map area. This lets users browse previously-viewed areas without an internet connection. Use the Cache API in a service worker to intercept tile requests and serve cached tiles. Show an offline indicator banner when no network is detected.

User-contributed location photos

Add a photo upload section to the Add Review dialog. Users can attach up to 3 photos per review. Store them in a Supabase Storage public bucket at locations/{location_id}/{review_id}_{index}.jpg. Show a photo gallery in the LocationDetail Sheet using a horizontal scroll of thumbnails that expand in a Lightbox Dialog on click.

Embeddable map widget

Add a /embed/map URL that renders the map without the app shell (no header, no sidebar). Accept URL parameters for default center, zoom level, and category filter. Generate an embed code snippet in the dashboard that produces an iframe tag. This lets users embed the public map on their own websites.

Common pitfalls

Pitfall: Fetching all locations on mount instead of using viewport queries

How to avoid: Always use the get_locations_in_bounds RPC function tied to the map's current bounds. The initial bounds come from the map's load event. Use debouncing on the moveend event to avoid over-fetching during fast panning.

Pitfall: Storing latitude and longitude as float columns instead of a geography type

How to avoid: Use the geography(POINT, 4326) column type from the start. Insert using ST_MakePoint(lng, lat)::geography. The GIST spatial index on this column makes ST_DWithin queries fast even with millions of rows.

Pitfall: Not cleaning up the Leaflet map instance when the React component unmounts

How to avoid: In the useEffect that initializes the map, return a cleanup function: return () => { map.remove() }. Store the map instance in a ref (useRef<L.Map | null>()) and check if it already exists before initializing.

Pitfall: Using ST_MakePoint(lat, lng) instead of ST_MakePoint(lng, lat)

How to avoid: Always call ST_MakePoint(longitude, latitude) — X (longitude) first, Y (latitude) second. Verify by checking that a known location appears in the correct city on the map after inserting test data.

Best practices

  • Use Leaflet.markercluster for any map with more than 50 locations. Without clustering, 500+ markers cause severe browser performance issues — each marker is a DOM element.
  • Set a meaningful default bounding box on app load rather than just a center point and zoom. Use the user's approximate location from navigator.geolocation if available, falling back to a city that matches your app's primary use case.
  • Add a minimum zoom level check before firing viewport queries. At zoom level 1 (world view), the bounding box covers the entire world — an unbounded query that ignores your spatial index. Only fire viewport queries at zoom level 8 or higher.
  • Normalize categories to lowercase slugs in the database (restaurant, not 'Restaurant' or 'RESTAURANT'). Use a CHECK constraint: ADD CONSTRAINT locations_category_check CHECK (category IN ('restaurant', 'park', 'hotel', 'shop', 'other')).
  • Cache geocoding results for 30 days by storing address → coordinates pairs in a geocoding_cache table. Geocoding the same address repeatedly wastes API quota — most addresses do not change their coordinates.
  • Use a private Supabase Storage bucket for user-submitted photos and generate signed URLs with a 24-hour expiry for display. Never serve user-uploaded images from a public bucket without content moderation.
  • Add a is_verified flag and a moderation queue for user-submitted locations. Show unverified locations with a different marker color. Implement a simple admin page where verified users can approve or reject pending location submissions.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a map application with Supabase PostGIS. I have a locations table with a geography(POINT, 4326) column called 'coordinates'. Write a PostgreSQL function get_locations_in_bounds(north float, south float, east float, west float) that returns locations within the bounding box using ST_MakeEnvelope. Also write a function get_nearby_locations(lat float, lng float, radius_meters float) that returns locations ordered by distance using ST_DWithin and ST_Distance. Include the distance in meters in the result.

Lovable Prompt

Add a 'Near Me' feature to the map page. When the user clicks 'Near Me', call navigator.geolocation.getCurrentPosition to get the user's coordinates. Then call the get_nearby_locations Supabase RPC function with those coordinates and a 2km radius. Display the results as a list in a Sheet that slides in from the left, sorted by distance. Each item shows location name, category badge, distance in meters, and a 'Show on Map' button that pans the map and highlights the marker.

Build Prompt

In Supabase, create an Edge Function that accepts an array of address strings and batch-geocodes them using Nominatim OpenStreetMap. For each address, call https://nominatim.openstreetmap.org/search with a 1-second delay between requests (Nominatim rate limit is 1 request/second). For each successful result, INSERT a locations row using ST_MakePoint(lng, lat)::geography for the coordinates column. Return a summary of successfully inserted vs failed addresses.

Frequently asked questions

Do I need to pay for Google Maps to use Leaflet?

No. Leaflet is a free, open-source mapping library that works with any tile provider. This guide uses OpenStreetMap tiles, which are completely free with attribution required. For geocoding (address to coordinates), you can use Nominatim (free, rate-limited to 1 request/second) instead of Google Maps Geocoding API. Google Maps API is only needed if you want higher geocoding request limits or Google's proprietary place data.

What is PostGIS and why is it better than storing lat/lng as floats?

PostGIS adds geographic data types and spatial indexing to PostgreSQL. Storing coordinates as a geography(POINT) column enables spatial queries using SQL functions like ST_DWithin (distance search) and ST_MakeEnvelope (bounding box search), backed by a GIST index that makes these queries fast even with millions of rows. Lat/lng floats require a full table scan for any spatial filtering.

How do I enable PostGIS in my Supabase project?

In the Supabase dashboard, go to Database → Extensions. Search for 'postgis' and click Enable. This enables PostGIS on your project's PostgreSQL database. No additional cost — PostGIS is included in all Supabase plans. After enabling, you can use geography data types and spatial functions in your SQL queries and migrations.

Why do my Leaflet markers sometimes not show up in Lovable?

Leaflet has a known issue with default marker icons in webpack/Vite environments: the CSS references icon images with relative paths that break in bundled builds. Fix it by explicitly importing the icon images from the leaflet package and setting them: import iconUrl from 'leaflet/dist/images/marker-icon.png'; import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; L.Icon.Default.mergeOptions({ iconUrl, shadowUrl }). Ask Lovable to add this fix after the Leaflet import.

Can I use Google Maps instead of Leaflet?

Yes, but it requires a Google Maps API key (with Maps JavaScript API enabled) and has per-load billing after the free tier. Leaflet with OpenStreetMap is free for most use cases. If you need Google-specific features (Street View, Google Places autocomplete, transit routing), switch to Google Maps by replacing the Leaflet initialization with the Google Maps JavaScript API script. The PostGIS backend and Edge Functions work identically regardless of which map library you use.

How many markers can the map handle before performance degrades?

Without clustering, Leaflet starts to slow down at 500–1,000 markers because each marker is a DOM element. With Leaflet.markercluster, you can comfortably display 10,000+ markers — most are hidden in clusters at higher zoom levels. Beyond 50,000 markers, consider using a canvas-based renderer like Leaflet.PixiOverlay or switching to Mapbox GL JS, which renders markers on the GPU.

Is there help available to build a more advanced mapping application?

Yes. RapidDev builds production map applications including real-time location tracking, routing and navigation, geofencing alerts, and PostGIS analytics. Reach out if your mapping needs go beyond static point data and viewport queries.

How do I handle coordinates in countries that cross the anti-meridian (180° longitude)?

For apps that include regions near the International Date Line (Pacific Islands, eastern Russia), bounding box queries need to handle longitude ranges that wrap around 180°/-180°. PostGIS's geography type handles this correctly — ST_MakeEnvelope with an east value less than west is interpreted as wrapping around the anti-meridian. Leaflet handles this with the worldCopyJump option and by setting bounds that cross ±180. For most apps serving a single country or continent, this edge case does not apply.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.