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
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
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.
1Set up a PostGIS-powered map database in Supabase:231. Enable PostGIS: CREATE EXTENSION IF NOT EXISTS postgis;452. 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_at7 - location_reviews: id, location_id, user_id, rating (int 1-5), comment (text), created_at8 - location_images: id, location_id, user_id, storage_path (text), caption (text), created_at9103. 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.14154. Spatial index: CREATE INDEX idx_locations_coordinates ON locations USING GIST (coordinates);16175. Regular indexes: CREATE INDEX idx_locations_category ON locations(category, is_active);18196. 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_url22237. 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_limitPro 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.
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.
1Build a MapView component at src/components/MapView.tsx.23Requirements: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 attribution6- 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 | null8- 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=gray11- 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 popup13- Show a 'Locate Me' button (crosshair icon) that calls navigator.geolocation.getCurrentPosition and pans the map to the user's coordinates14- Clean up the Leaflet instance in the useEffect return function to prevent memory leaksPro 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.
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.
1Build two things:231. 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_id7- Return { lat, lng, formatted_address, place_id }8- If using Nominatim (free): call https://nominatim.openstreetmap.org/search?format=json&q={encoded}&limit=1 instead9- Handle geocoding failures (zero_results, request_denied) with appropriate error messages10112. Add Location form at src/components/AddLocationForm.tsx:12- A Dialog with a multi-step form:13 Step 1: Address search14 - Text Input: 'Enter address or place name'15 - Search Button that calls geocode-address Edge Function16 - Shows a mini-map preview (or just displays the returned formatted_address for confirmation)17 - 'Use this location' Button advances to step 218 Step 2: Location details19 - Name Input (required)20 - Category Select: restaurant, park, hotel, shop, other21 - Description Textarea22 - Phone Input (optional)23 - Website Input (optional)24 - Tags Input (comma-separated, displays as Badges)25 Step 3: Submit26 - 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 Function27 - Show success Toast and close dialogExpected 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.
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.
1Build the main map page at src/pages/MapPage.tsx and a LocationDetail component.231. MapPage.tsx:4- State: locations: Location[], selectedLocation: Location | null, categoryFilter: string | null, isLoading: boolean5- 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-fetches8- 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 MapView10112. LocationDetail Sheet:12- Props: location: Location | null, onClose: () => void13- 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 top16- Body sections:17 - Address with a 'Get Directions' link (https://maps.google.com/?q={lat},{lng})18 - Phone and Website (if present)19 - Description20 - Tags as Badges21 - Average rating (from location_reviews aggregate) and review count22 - Recent reviews list: last 3 reviews with star rating and comment23 - 'Add Review' Button that opens a nested DialogPro 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
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'23const corsHeaders = {4 'Access-Control-Allow-Origin': '*',5 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',6 'Content-Type': 'application/json',7}89interface GeocodeResult {10 lat: number11 lng: number12 formatted_address: string13 place_id?: string14}1516serve(async (req: Request) => {17 if (req.method === 'OPTIONS') {18 return new Response('ok', { headers: corsHeaders })19 }2021 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 }2930 const apiKey = Deno.env.get('GEOCODING_API_KEY')31 let result: GeocodeResult3233 if (apiKey) {34 // Google Maps Geocoding API35 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()4041 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 }4748 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()6364 if (!data?.length) {65 return new Response(66 JSON.stringify({ error: 'Address not found' }),67 { status: 422, headers: corsHeaders }68 )69 }7071 result = {72 lat: parseFloat(data[0].lat),73 lng: parseFloat(data[0].lon),74 formatted_address: data[0].display_name,75 }76 }7778 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation