# How to Build a Map Application with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

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

```
Set up a PostGIS-powered map database in Supabase:

1. Enable PostGIS: CREATE EXTENSION IF NOT EXISTS postgis;

2. Create tables:
   - 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
   - location_reviews: id, location_id, user_id, rating (int 1-5), comment (text), created_at
   - location_images: id, location_id, user_id, storage_path (text), caption (text), created_at

3. RLS:
   - locations: public SELECT WHERE is_active = true. Authenticated users can INSERT their own rows. Users can UPDATE their own rows.
   - location_reviews: public SELECT. Authenticated users can INSERT their own reviews.
   - location_images: public SELECT. Authenticated users can INSERT.

4. Spatial index: CREATE INDEX idx_locations_coordinates ON locations USING GIST (coordinates);

5. Regular indexes: CREATE INDEX idx_locations_category ON locations(category, is_active);

6. SQL function get_locations_in_bounds(north float, south float, east float, west float, p_category text DEFAULT NULL):
   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)
   Include columns: id, name, category, address, ST_X(coordinates::geometry) as lng, ST_Y(coordinates::geometry) as lat, is_verified, tags, image_url

7. SQL function get_nearby_locations(p_lat float, p_lng float, p_radius_meters float DEFAULT 5000, p_limit int DEFAULT 20):
   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.

```
Build a MapView component at src/components/MapView.tsx.

Requirements:
- Use Leaflet (import via 'leaflet' — Lovable will install it). Import CSS: import 'leaflet/dist/leaflet.css'
- 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
- Default center: props.defaultCenter or [37.7749, -122.4194] (San Francisco). Default zoom: 12.
- Accept props: locations: Location[], onBoundsChange: (bounds: Bounds) => void, onMarkerClick: (location: Location) => void, selectedLocationId: string | null
- When bounds change (map moveend event), call onBoundsChange with { north, south, east, west } from map.getBounds()
- Render locations as custom markers. Create a custom icon per category using L.divIcon with a colored dot:
  - restaurant=red, park=green, hotel=blue, shop=yellow, other=gray
- 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.
- When selectedLocationId changes, pan the map to that location with map.panTo and open its popup
- Show a 'Locate Me' button (crosshair icon) that calls navigator.geolocation.getCurrentPosition and pans the map to the user's coordinates
- 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.

```
Build two things:

1. Edge Function at supabase/functions/geocode-address/index.ts:
- Accept POST body: { address: string }
- Call Google Maps Geocoding API: https://maps.googleapis.com/maps/api/geocode/json?address={encoded}&key={GEOCODING_API_KEY}
- From the response, extract: formatted_address, lat and lng from geometry.location, place_id
- Return { lat, lng, formatted_address, place_id }
- If using Nominatim (free): call https://nominatim.openstreetmap.org/search?format=json&q={encoded}&limit=1 instead
- Handle geocoding failures (zero_results, request_denied) with appropriate error messages

2. Add Location form at src/components/AddLocationForm.tsx:
- A Dialog with a multi-step form:
  Step 1: Address search
  - Text Input: 'Enter address or place name'
  - Search Button that calls geocode-address Edge Function
  - Shows a mini-map preview (or just displays the returned formatted_address for confirmation)
  - 'Use this location' Button advances to step 2
  Step 2: Location details
  - Name Input (required)
  - Category Select: restaurant, park, hotel, shop, other
  - Description Textarea
  - Phone Input (optional)
  - Website Input (optional)
  - Tags Input (comma-separated, displays as Badges)
  Step 3: Submit
  - 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
  - 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.

```
Build the main map page at src/pages/MapPage.tsx and a LocationDetail component.

1. MapPage.tsx:
- State: locations: Location[], selectedLocation: Location | null, categoryFilter: string | null, isLoading: boolean
- When onBoundsChange fires, debounce by 300ms then call supabase.rpc('get_locations_in_bounds', { north, south, east, west, p_category: categoryFilter })
- Set loading indicator on the map during fetch (a small spinner overlay in the top-left)
- 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
- 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.
- Pass selectedLocation.id as selectedLocationId to MapView

2. LocationDetail Sheet:
- Props: location: Location | null, onClose: () => void
- Sheet opens from the right (side='right', width 400px)
- Header: location name, category Badge, is_verified Badge (checkmark icon)
- If image_url exists, show full-width image at top
- Body sections:
  - Address with a 'Get Directions' link (https://maps.google.com/?q={lat},{lng})
  - Phone and Website (if present)
  - Description
  - Tags as Badges
  - Average rating (from location_reviews aggregate) and review count
  - Recent reviews list: last 3 reviews with star rating and comment
  - '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 example

File: `supabase/functions/geocode-address/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

interface GeocodeResult {
  lat: number
  lng: number
  formatted_address: string
  place_id?: string
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    const { address } = await req.json()
    if (!address?.trim()) {
      return new Response(
        JSON.stringify({ error: 'Address is required' }),
        { status: 400, headers: corsHeaders }
      )
    }

    const apiKey = Deno.env.get('GEOCODING_API_KEY')
    let result: GeocodeResult

    if (apiKey) {
      // Google Maps Geocoding API
      const encoded = encodeURIComponent(address)
      const res = await fetch(
        `https://maps.googleapis.com/maps/api/geocode/json?address=${encoded}&key=${apiKey}`
      )
      const data = await res.json()

      if (data.status !== 'OK' || !data.results?.length) {
        return new Response(
          JSON.stringify({ error: `Geocoding failed: ${data.status}` }),
          { status: 422, headers: corsHeaders }
        )
      }

      const first = data.results[0]
      result = {
        lat: first.geometry.location.lat,
        lng: first.geometry.location.lng,
        formatted_address: first.formatted_address,
        place_id: first.place_id,
      }
    } else {
      // Nominatim fallback (free, no key required)
      const encoded = encodeURIComponent(address)
      const res = await fetch(
        `https://nominatim.openstreetmap.org/search?format=json&q=${encoded}&limit=1`,
        { headers: { 'User-Agent': 'MapApplication/1.0' } }
      )
      const data = await res.json()

      if (!data?.length) {
        return new Response(
          JSON.stringify({ error: 'Address not found' }),
          { status: 422, headers: corsHeaders }
        )
      }

      result = {
        lat: parseFloat(data[0].lat),
        lng: parseFloat(data[0].lon),
        formatted_address: data[0].display_name,
      }
    }

    return new Response(JSON.stringify(result), { headers: corsHeaders })
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error'
    return new Response(
      JSON.stringify({ error: message }),
      { status: 500, headers: corsHeaders }
    )
  }
})
```

## Common mistakes

- **Fetching all locations on mount instead of using viewport queries** — A locations table with 10,000 rows takes seconds to load and sends unnecessary data to the browser. On mobile connections, this can make the map unusable. Fix: 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.
- **Storing latitude and longitude as float columns instead of a geography type** — Float columns cannot use PostGIS spatial indexes. Distance and bounding box queries require full table scans — slow at any meaningful scale. You also lose access to all PostGIS geometry functions. Fix: 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.
- **Not cleaning up the Leaflet map instance when the React component unmounts** — Leaflet attaches event listeners and creates a DOM node. If the component remounts (e.g. navigating away and back), a second Leaflet instance tries to attach to the same DOM node, causing errors and duplicate tile layers. Fix: 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.
- **Using ST_MakePoint(lat, lng) instead of ST_MakePoint(lng, lat)** — PostGIS and geographic coordinate systems use longitude first, latitude second — the opposite of how most people think about 'lat, lng'. Swapping them places your markers in the wrong location, potentially in the ocean. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/map-application
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/map-application
