# How to Build a Map Application with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build an interactive map application in Replit in 1-2 hours. Use Replit Agent to generate an Express + PostgreSQL app with react-leaflet, viewport-based marker loading, Haversine nearby search, category filtering, and user favorites. OpenStreetMap tiles are free with no API key. Deploy on Autoscale.

## Before you start

- A Replit account (Free plan is sufficient for development)
- A list of location categories for your use case (e.g., Restaurant, Park, Shop, Hotel)
- Optional: OpenCage API key (free tier: 2,500 requests/day) or Mapbox token for geocoding
- Optional: sample location data with latitude/longitude coordinates to test the map

## Step-by-step guide

### 1. Scaffold the project with Replit Agent

Create a new Repl and use the Agent prompt below to generate the full Express + PostgreSQL map application with Drizzle schema, geospatial routes, and React frontend with react-leaflet.

```
// Type this into Replit Agent:
// Build a map application with Express, PostgreSQL using Drizzle ORM, and React.
// Tables:
// - locations: id serial pk, name text not null, description text, category text,
//   latitude numeric not null, longitude numeric not null, address text, city text,
//   state text, country text, metadata jsonb, image_url text, creator_id text,
//   is_active boolean default true, created_at timestamp default now()
// - location_categories: id serial, name text unique not null, icon text,
//   color text default '#3B82F6', position integer default 0
// - user_favorites: id serial, user_id text not null, location_id integer FK locations,
//   created_at timestamp default now(), unique(user_id, location_id)
// Add a compound index on (latitude, longitude).
// Routes:
// GET /api/locations?min_lat=&max_lat=&min_lng=&max_lng=&category= (viewport query)
// GET /api/locations/:id (detail)
// POST /api/locations (create with optional geocoding)
// PUT /api/locations/:id (update)
// DELETE /api/locations/:id
// GET /api/locations/nearby?lat=&lng=&radius_km= (Haversine formula)
// POST /api/locations/:id/favorite (toggle saved)
// GET /api/favorites (user's saved locations)
// GET /api/geocode?address= (forward geocode using OpenCage API)
// GET /api/categories
// React frontend with react-leaflet full-screen map, react-leaflet-cluster for marker clustering,
// category-colored markers, sidebar panel on marker click, search bar, category filter buttons,
// Add Location form with click-on-map coordinate selection.
// Use Replit Auth. Bind server to 0.0.0.0.
```

> Pro tip: After Agent creates the schema, add the latitude/longitude compound index in the Replit SQL Editor: CREATE INDEX idx_locations_coords ON locations(latitude, longitude). This dramatically speeds up viewport bounding box queries.

**Expected result:** A running Express app with a full-screen map showing OpenStreetMap tiles. The markers panel is empty until you add location data via the Add Location form.

### 2. Build the viewport bounding box query

The key route for map performance: return only locations within the current map viewport. The frontend sends the map bounds and this query filters by bounding box, then returns markers to render.

```
const express = require('express');
const { db } = require('../db');
const { locations, locationCategories } = require('../../shared/schema');
const { eq, and, gte, lte, between } = require('drizzle-orm');

const router = express.Router();

// GET /api/locations?min_lat=40.0&max_lat=41.0&min_lng=-74.5&max_lng=-73.5&category=restaurant
router.get('/', async (req, res) => {
  const { min_lat, max_lat, min_lng, max_lng, category } = req.query;

  const conditions = [eq(locations.isActive, true)];

  // Viewport bounding box filter
  if (min_lat && max_lat) {
    conditions.push(between(locations.latitude, parseFloat(min_lat), parseFloat(max_lat)));
  }
  if (min_lng && max_lng) {
    conditions.push(between(locations.longitude, parseFloat(min_lng), parseFloat(max_lng)));
  }
  if (category) {
    conditions.push(eq(locations.category, category));
  }

  const rows = await db
    .select({
      id: locations.id,
      name: locations.name,
      category: locations.category,
      latitude: locations.latitude,
      longitude: locations.longitude,
      address: locations.address,
      imageUrl: locations.imageUrl,
    })
    .from(locations)
    .where(and(...conditions))
    .limit(500); // Safety cap — clustering handles visual overload

  res.json(rows);
});

// GET /api/locations/:id — detail with full metadata
router.get('/:id', async (req, res) => {
  const [location] = await db
    .select()
    .from(locations)
    .where(eq(locations.id, parseInt(req.params.id)));

  if (!location) return res.status(404).json({ error: 'Location not found' });
  res.json(location);
});

module.exports = router;
```

**Expected result:** GET /api/locations?min_lat=40.0&max_lat=41.0&min_lng=-74.5&max_lng=-73.5 returns only locations within those bounds. Panning the map triggers a new request with updated bounds.

### 3. Build the Haversine nearby search

The nearby search finds all locations within a radius in kilometers using the Haversine formula in a raw SQL query. This powers a 'Find locations near me' feature using the browser's geolocation API.

```
const { sql } = require('drizzle-orm');

// GET /api/locations/nearby?lat=40.7128&lng=-74.0060&radius_km=5
router.get('/nearby', async (req, res) => {
  const { lat, lng, radius_km = 5 } = req.query;

  if (!lat || !lng) {
    return res.status(400).json({ error: 'lat and lng query params are required' });
  }

  const latNum = parseFloat(lat);
  const lngNum = parseFloat(lng);
  const radiusNum = parseFloat(radius_km);

  // Haversine formula in PostgreSQL
  // Returns distance in km using Earth radius 6371
  const nearby = await db.execute(sql`
    SELECT
      id, name, category, latitude, longitude, address, image_url,
      ROUND(
        acos(
          sin(radians(${latNum})) * sin(radians(latitude)) +
          cos(radians(${latNum})) * cos(radians(latitude)) *
          cos(radians(longitude - ${lngNum}))
        ) * 6371
      , 2) AS distance_km
    FROM locations
    WHERE is_active = true
      AND acos(
        sin(radians(${latNum})) * sin(radians(latitude)) +
        cos(radians(${latNum})) * cos(radians(latitude)) *
        cos(radians(longitude - ${lngNum}))
      ) * 6371 < ${radiusNum}
    ORDER BY distance_km ASC
    LIMIT 50
  `);

  res.json(nearby.rows);
});
```

> Pro tip: For very large location datasets (10,000+ rows), the Haversine formula without spatial indexing scans every row. At that scale, consider enabling the PostGIS extension (available on Neon or Supabase) which provides native <-> distance operators with spatial indexes.

**Expected result:** GET /api/locations/nearby?lat=40.7128&lng=-74.0060&radius_km=2 returns all locations within 2km of Manhattan, sorted by distance with the distance_km value included in each result.

### 4. Add geocoding and the favorites toggle

The geocoding endpoint converts a typed address to lat/lng coordinates for the Add Location form and the search bar. Favorites are toggled with a single POST that inserts or deletes based on whether a row already exists.

```
const axios = require('axios');
const { userFavorites } = require('../../shared/schema');

// GET /api/geocode?address=1600+Pennsylvania+Ave+Washington+DC
router.get('/geocode', async (req, res) => {
  const { address } = req.query;
  if (!address) return res.status(400).json({ error: 'address param required' });

  const apiKey = process.env.OPENCAGE_API_KEY;
  if (!apiKey) {
    return res.status(503).json({ error: 'Geocoding not configured. Add OPENCAGE_API_KEY to Replit Secrets.' });
  }

  try {
    const response = await axios.get('https://api.opencagedata.com/geocode/v1/json', {
      params: { q: address, key: apiKey, limit: 5, no_annotations: 1 },
    });
    const results = response.data.results.map(r => ({
      formatted: r.formatted,
      lat: r.geometry.lat,
      lng: r.geometry.lng,
    }));
    res.json(results);
  } catch (err) {
    res.status(500).json({ error: 'Geocoding request failed' });
  }
});

// POST /api/locations/:id/favorite — toggle
router.post('/:id/favorite', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const locationId = parseInt(req.params.id);

  const existing = await db
    .select()
    .from(userFavorites)
    .where(and(eq(userFavorites.userId, userId), eq(userFavorites.locationId, locationId)));

  if (existing.length > 0) {
    await db.delete(userFavorites).where(eq(userFavorites.id, existing[0].id));
    return res.json({ favorited: false });
  }

  await db.insert(userFavorites).values({ userId, locationId });
  res.json({ favorited: true });
});
```

> Pro tip: Store OPENCAGE_API_KEY in Replit Secrets (lock icon in sidebar). The free OpenCage tier provides 2,500 geocoding requests per day — more than enough for development and small production apps.

### 5. Configure the React Leaflet map and deploy on Autoscale

The frontend map component sends debounced viewport queries as the user pans and zooms. Marker clustering handles visual performance with many pins. Deploy on Autoscale — map tile requests go to the OpenStreetMap CDN, not your server.

```
// client/src/components/Map.jsx — React Leaflet map with viewport loading
// Install: npm install react-leaflet leaflet react-leaflet-cluster

import { useEffect, useState, useCallback } from 'react';
import { MapContainer, TileLayer, useMapEvents, Marker, Popup } from 'react-leaflet';
import MarkerClusterGroup from 'react-leaflet-cluster';
import 'leaflet/dist/leaflet.css';

function MapEventHandler({ onBoundsChange }) {
  const map = useMapEvents({
    moveend: () => onBoundsChange(map.getBounds()),
    zoomend: () => onBoundsChange(map.getBounds()),
  });
  return null;
}

export function MapView({ selectedCategory }) {
  const [markers, setMarkers] = useState([]);
  const [selectedLocation, setSelectedLocation] = useState(null);

  const fetchMarkers = useCallback(async (bounds) => {
    const params = new URLSearchParams({
      min_lat: bounds.getSouth(),
      max_lat: bounds.getNorth(),
      min_lng: bounds.getWest(),
      max_lng: bounds.getEast(),
      ...(selectedCategory && { category: selectedCategory }),
    });
    const res = await fetch(`/api/locations?${params}`);
    const data = await res.json();
    setMarkers(data);
  }, [selectedCategory]);

  // Debounce map movement to avoid hammering the API
  let debounceTimer;
  const handleBoundsChange = (bounds) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => fetchMarkers(bounds), 300);
  };

  return (
    <MapContainer center={[40.7128, -74.006]} zoom={13} style={{ height: '100vh', width: '100%' }}>
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; OpenStreetMap contributors'
      />
      <MapEventHandler onBoundsChange={handleBoundsChange} />
      <MarkerClusterGroup>
        {markers.map(loc => (
          <Marker
            key={loc.id}
            position={[parseFloat(loc.latitude), parseFloat(loc.longitude)]}
            eventHandlers={{ click: () => setSelectedLocation(loc) }}
          >
            <Popup>{loc.name}</Popup>
          </Marker>
        ))}
      </MarkerClusterGroup>
    </MapContainer>
  );
}
```

> Pro tip: Deploy on Autoscale — the heavy work of serving map tiles is done by the OpenStreetMap CDN, not your Express server. Your server only serves marker data, which is lightweight.

**Expected result:** The map shows clustered markers that expand as you zoom in. Panning triggers a new API call 300ms after the user stops moving, loading only visible markers.

## Complete code example

File: `server/routes/locations.js`

```javascript
const express = require('express');
const { db } = require('../db');
const { locations, userFavorites } = require('../../shared/schema');
const { eq, and, between, sql } = require('drizzle-orm');

const router = express.Router();

// GET /api/locations — viewport bounding box query
router.get('/', async (req, res) => {
  const { min_lat, max_lat, min_lng, max_lng, category } = req.query;
  const conditions = [eq(locations.isActive, true)];
  if (min_lat && max_lat) conditions.push(between(locations.latitude, parseFloat(min_lat), parseFloat(max_lat)));
  if (min_lng && max_lng) conditions.push(between(locations.longitude, parseFloat(min_lng), parseFloat(max_lng)));
  if (category) conditions.push(eq(locations.category, category));

  const rows = await db.select({
    id: locations.id, name: locations.name, category: locations.category,
    latitude: locations.latitude, longitude: locations.longitude,
    address: locations.address, imageUrl: locations.imageUrl,
  }).from(locations).where(and(...conditions)).limit(500);

  res.json(rows);
});

// GET /api/locations/nearby — Haversine formula
router.get('/nearby', async (req, res) => {
  const { lat, lng, radius_km = 5 } = req.query;
  if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' });

  const result = await db.execute(sql`
    SELECT id, name, category, latitude, longitude, address, image_url,
      ROUND(acos(
        sin(radians(${parseFloat(lat)})) * sin(radians(latitude)) +
        cos(radians(${parseFloat(lat)})) * cos(radians(latitude)) *
        cos(radians(longitude - ${parseFloat(lng)}))
      ) * 6371, 2) AS distance_km
    FROM locations
    WHERE is_active = true
      AND acos(
        sin(radians(${parseFloat(lat)})) * sin(radians(latitude)) +
        cos(radians(${parseFloat(lat)})) * cos(radians(latitude)) *
        cos(radians(longitude - ${parseFloat(lng)}))
      ) * 6371 < ${parseFloat(radius_km)}
    ORDER BY distance_km ASC LIMIT 50
  `);
  res.json(result.rows);
});

// POST /api/locations/:id/favorite — toggle
router.post('/:id/favorite', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });
  const locationId = parseInt(req.params.id);
  const existing = await db.select().from(userFavorites)
    .where(and(eq(userFavorites.userId, userId), eq(userFavorites.locationId, locationId)));
  if (existing.length > 0) {
    await db.delete(userFavorites).where(eq(userFavorites.id, existing[0].id));
    return res.json({ favorited: false });
  }
  await db.insert(userFavorites).values({ userId, locationId });
  res.json({ favorited: true });
});

module.exports = router;
```

## Common mistakes

- **Loading all locations on page load instead of using viewport queries** — Rendering 1,000 individual Leaflet markers simultaneously crashes mobile browsers and makes the initial load very slow. Fix: Always pass the current map bounds to the GET /api/locations query. The bounding box filter limits results to what's actually visible. Use react-leaflet-cluster for remaining markers.
- **Forgetting the compound latitude/longitude index** — Without an index on (latitude, longitude), every viewport query does a full table scan that slows down as the locations table grows. Fix: Run CREATE INDEX idx_locations_coords ON locations(latitude, longitude) in the Replit SQL Editor immediately after creating the tables. The BETWEEN query on both columns uses this index efficiently.
- **Calling the viewport API on every mousemove event** — Map pan events fire many times per second during movement. Without debouncing, this causes hundreds of API requests per second and makes the map feel laggy. Fix: Debounce the API call with a 300ms delay using clearTimeout/setTimeout. The fetch only fires 300ms after the user stops moving the map.
- **Storing the geocoding API key in client-side code** — React's build process bundles all JavaScript, making any hardcoded API key visible in the browser's network tab or source view. Fix: Put OPENCAGE_API_KEY in Replit Secrets and call the geocoding API from the Express backend (GET /api/geocode). The client calls your backend, which makes the external API call with the secret key.

## Best practices

- Use OpenStreetMap tiles with react-leaflet — they're free with no API key and have no monthly request limits for reasonable traffic.
- Store any geocoding API keys (OpenCage, Mapbox) in Replit Secrets (lock icon) and call geocoding from the Express backend, not the React frontend.
- Add the compound index on (latitude, longitude) immediately after creating the schema — the viewport query is the most-run query in your app.
- Cap the viewport query at 500 results and rely on react-leaflet-cluster for visual grouping. Rendering more than 500 individual markers degrades browser performance.
- Debounce map movement events at 300ms before triggering API requests — maps fire dozens of events per second during panning.
- Use the Haversine formula for nearby searches with datasets under 50,000 locations. At larger scale, consider PostGIS with ST_DWithin for spatial indexing.
- Deploy on Autoscale — map tile traffic goes directly to the OpenStreetMap CDN, not your server. Your Express app only handles lightweight marker queries.

## Frequently asked questions

### Do I need to pay for map tiles?

No. react-leaflet uses OpenStreetMap tiles by default, which are completely free with no API key required. For higher-quality tiles or custom styling, Mapbox has a free tier (50,000 map loads/month). OpenStreetMap's usage policy asks that high-traffic apps (millions of requests/month) use a tile CDN like Stadia Maps or MapTiler instead.

### How do I get the user's current location?

Use the browser's Geolocation API: navigator.geolocation.getCurrentPosition(position => { const { latitude, longitude } = position.coords; }). Show a 'Find Near Me' button that calls this, then pass the coordinates to your GET /api/locations/nearby endpoint with a default radius of 5km.

### Can I build a store locator with this?

Yes — that's one of the most common use cases. Add your store locations to the locations table with a category of 'store'. The viewport query loads markers as users browse. The nearby search powers a 'Find stores near me' feature. Add hours and phone number to the metadata JSONB column.

### What Replit plan do I need?

The Free plan is sufficient for development. For a public-facing app with a custom URL, deploy on Autoscale (Core plan or higher). Map tile traffic goes to the OpenStreetMap CDN, not your server, so Autoscale works well even with moderate traffic.

### How do I handle thousands of locations without crashing the browser?

Two mechanisms work together: the viewport bounding box query limits API results to locations within the current map view, and react-leaflet-cluster groups nearby markers at low zoom levels. You'd need millions of locations in a single city before either mechanism strains.

### Can I let users add locations without login?

Yes — remove the authentication check from the POST /api/locations route. To prevent spam, add a moderation field (status = 'pending') and build an admin review queue that sets status to 'active'. Only active locations appear on the map.

### Can RapidDev help me build a custom map application?

Yes. RapidDev has built 600+ apps including location-based tools and directory platforms. They can add custom map styles, real-time location updates, route planning, or integration with your existing data sources. Book a free consultation at rapidevelopers.com.

### How accurate is the Haversine formula for nearby searches?

The Haversine formula calculates great-circle distance on a sphere and is accurate to within 0.3% for typical distances under a few hundred kilometers. For city-scale nearby searches (under 50km), it's more than accurate enough. For very large areas, the Vincenty formula is more precise but rarely needed.

---

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