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.
What you're building
Map applications are one of the most universally useful tools you can build — store locators, property maps, event maps, hiking trail finders, restaurant guides. Any time your data has a location, a map beats a list. But most map tutorials show you how to drop a single pin, not how to build a performant app with hundreds or thousands of locations.
Replit Agent generates the full Express backend and React frontend in one prompt. The key technical insight in this build is viewport-based loading: instead of fetching all locations on page load, the frontend sends the current map bounds (northwest and southeast corners) and the API returns only locations within that bounding box. As the user pans and zooms, new markers load. A compound index on (latitude, longitude) makes these bounding box queries fast.
The map uses react-leaflet with free OpenStreetMap tiles — no API key, no billing surprises. Marker clustering (react-leaflet-cluster) groups nearby markers at low zoom levels, which prevents the browser from trying to render 500 individual pins simultaneously. For address search, a geocoding API (OpenCage has a free tier) converts typed addresses to coordinates. Store your geocoding API key in Replit Secrets.
Final result
A fully functional map application with viewport-based marker loading, clustering, Haversine nearby search, category filters, and user favorites — deployed on Replit Autoscale with free OpenStreetMap tiles.
Tech stack
Prerequisites
- 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
Build steps
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.
1// Type this into Replit Agent:2// Build a map application with Express, PostgreSQL using Drizzle ORM, and React.3// Tables:4// - locations: id serial pk, name text not null, description text, category text,5// latitude numeric not null, longitude numeric not null, address text, city text,6// state text, country text, metadata jsonb, image_url text, creator_id text,7// is_active boolean default true, created_at timestamp default now()8// - location_categories: id serial, name text unique not null, icon text,9// color text default '#3B82F6', position integer default 010// - user_favorites: id serial, user_id text not null, location_id integer FK locations,11// created_at timestamp default now(), unique(user_id, location_id)12// Add a compound index on (latitude, longitude).13// Routes:14// GET /api/locations?min_lat=&max_lat=&min_lng=&max_lng=&category= (viewport query)15// GET /api/locations/:id (detail)16// POST /api/locations (create with optional geocoding)17// PUT /api/locations/:id (update)18// DELETE /api/locations/:id19// GET /api/locations/nearby?lat=&lng=&radius_km= (Haversine formula)20// POST /api/locations/:id/favorite (toggle saved)21// GET /api/favorites (user's saved locations)22// GET /api/geocode?address= (forward geocode using OpenCage API)23// GET /api/categories24// React frontend with react-leaflet full-screen map, react-leaflet-cluster for marker clustering,25// category-colored markers, sidebar panel on marker click, search bar, category filter buttons,26// Add Location form with click-on-map coordinate selection.27// 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.
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.
1const express = require('express');2const { db } = require('../db');3const { locations, locationCategories } = require('../../shared/schema');4const { eq, and, gte, lte, between } = require('drizzle-orm');56const router = express.Router();78// GET /api/locations?min_lat=40.0&max_lat=41.0&min_lng=-74.5&max_lng=-73.5&category=restaurant9router.get('/', async (req, res) => {10 const { min_lat, max_lat, min_lng, max_lng, category } = req.query;1112 const conditions = [eq(locations.isActive, true)];1314 // Viewport bounding box filter15 if (min_lat && max_lat) {16 conditions.push(between(locations.latitude, parseFloat(min_lat), parseFloat(max_lat)));17 }18 if (min_lng && max_lng) {19 conditions.push(between(locations.longitude, parseFloat(min_lng), parseFloat(max_lng)));20 }21 if (category) {22 conditions.push(eq(locations.category, category));23 }2425 const rows = await db26 .select({27 id: locations.id,28 name: locations.name,29 category: locations.category,30 latitude: locations.latitude,31 longitude: locations.longitude,32 address: locations.address,33 imageUrl: locations.imageUrl,34 })35 .from(locations)36 .where(and(...conditions))37 .limit(500); // Safety cap — clustering handles visual overload3839 res.json(rows);40});4142// GET /api/locations/:id — detail with full metadata43router.get('/:id', async (req, res) => {44 const [location] = await db45 .select()46 .from(locations)47 .where(eq(locations.id, parseInt(req.params.id)));4849 if (!location) return res.status(404).json({ error: 'Location not found' });50 res.json(location);51});5253module.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.
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.
1const { sql } = require('drizzle-orm');23// GET /api/locations/nearby?lat=40.7128&lng=-74.0060&radius_km=54router.get('/nearby', async (req, res) => {5 const { lat, lng, radius_km = 5 } = req.query;67 if (!lat || !lng) {8 return res.status(400).json({ error: 'lat and lng query params are required' });9 }1011 const latNum = parseFloat(lat);12 const lngNum = parseFloat(lng);13 const radiusNum = parseFloat(radius_km);1415 // Haversine formula in PostgreSQL16 // Returns distance in km using Earth radius 637117 const nearby = await db.execute(sql`18 SELECT19 id, name, category, latitude, longitude, address, image_url,20 ROUND(21 acos(22 sin(radians(${latNum})) * sin(radians(latitude)) +23 cos(radians(${latNum})) * cos(radians(latitude)) *24 cos(radians(longitude - ${lngNum}))25 ) * 637126 , 2) AS distance_km27 FROM locations28 WHERE is_active = true29 AND acos(30 sin(radians(${latNum})) * sin(radians(latitude)) +31 cos(radians(${latNum})) * cos(radians(latitude)) *32 cos(radians(longitude - ${lngNum}))33 ) * 6371 < ${radiusNum}34 ORDER BY distance_km ASC35 LIMIT 5036 `);3738 res.json(nearby.rows);39});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.
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.
1const axios = require('axios');2const { userFavorites } = require('../../shared/schema');34// GET /api/geocode?address=1600+Pennsylvania+Ave+Washington+DC5router.get('/geocode', async (req, res) => {6 const { address } = req.query;7 if (!address) return res.status(400).json({ error: 'address param required' });89 const apiKey = process.env.OPENCAGE_API_KEY;10 if (!apiKey) {11 return res.status(503).json({ error: 'Geocoding not configured. Add OPENCAGE_API_KEY to Replit Secrets.' });12 }1314 try {15 const response = await axios.get('https://api.opencagedata.com/geocode/v1/json', {16 params: { q: address, key: apiKey, limit: 5, no_annotations: 1 },17 });18 const results = response.data.results.map(r => ({19 formatted: r.formatted,20 lat: r.geometry.lat,21 lng: r.geometry.lng,22 }));23 res.json(results);24 } catch (err) {25 res.status(500).json({ error: 'Geocoding request failed' });26 }27});2829// POST /api/locations/:id/favorite — toggle30router.post('/:id/favorite', async (req, res) => {31 const userId = req.user?.id;32 if (!userId) return res.status(401).json({ error: 'Login required' });3334 const locationId = parseInt(req.params.id);3536 const existing = await db37 .select()38 .from(userFavorites)39 .where(and(eq(userFavorites.userId, userId), eq(userFavorites.locationId, locationId)));4041 if (existing.length > 0) {42 await db.delete(userFavorites).where(eq(userFavorites.id, existing[0].id));43 return res.json({ favorited: false });44 }4546 await db.insert(userFavorites).values({ userId, locationId });47 res.json({ favorited: true });48});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.
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.
1// client/src/components/Map.jsx — React Leaflet map with viewport loading2// Install: npm install react-leaflet leaflet react-leaflet-cluster34import { useEffect, useState, useCallback } from 'react';5import { MapContainer, TileLayer, useMapEvents, Marker, Popup } from 'react-leaflet';6import MarkerClusterGroup from 'react-leaflet-cluster';7import 'leaflet/dist/leaflet.css';89function MapEventHandler({ onBoundsChange }) {10 const map = useMapEvents({11 moveend: () => onBoundsChange(map.getBounds()),12 zoomend: () => onBoundsChange(map.getBounds()),13 });14 return null;15}1617export function MapView({ selectedCategory }) {18 const [markers, setMarkers] = useState([]);19 const [selectedLocation, setSelectedLocation] = useState(null);2021 const fetchMarkers = useCallback(async (bounds) => {22 const params = new URLSearchParams({23 min_lat: bounds.getSouth(),24 max_lat: bounds.getNorth(),25 min_lng: bounds.getWest(),26 max_lng: bounds.getEast(),27 ...(selectedCategory && { category: selectedCategory }),28 });29 const res = await fetch(`/api/locations?${params}`);30 const data = await res.json();31 setMarkers(data);32 }, [selectedCategory]);3334 // Debounce map movement to avoid hammering the API35 let debounceTimer;36 const handleBoundsChange = (bounds) => {37 clearTimeout(debounceTimer);38 debounceTimer = setTimeout(() => fetchMarkers(bounds), 300);39 };4041 return (42 <MapContainer center={[40.7128, -74.006]} zoom={13} style={{ height: '100vh', width: '100%' }}>43 <TileLayer44 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"45 attribution='© OpenStreetMap contributors'46 />47 <MapEventHandler onBoundsChange={handleBoundsChange} />48 <MarkerClusterGroup>49 {markers.map(loc => (50 <Marker51 key={loc.id}52 position={[parseFloat(loc.latitude), parseFloat(loc.longitude)]}53 eventHandlers={{ click: () => setSelectedLocation(loc) }}54 >55 <Popup>{loc.name}</Popup>56 </Marker>57 ))}58 </MarkerClusterGroup>59 </MapContainer>60 );61}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
1const express = require('express');2const { db } = require('../db');3const { locations, userFavorites } = require('../../shared/schema');4const { eq, and, between, sql } = require('drizzle-orm');56const router = express.Router();78// GET /api/locations — viewport bounding box query9router.get('/', async (req, res) => {10 const { min_lat, max_lat, min_lng, max_lng, category } = req.query;11 const conditions = [eq(locations.isActive, true)];12 if (min_lat && max_lat) conditions.push(between(locations.latitude, parseFloat(min_lat), parseFloat(max_lat)));13 if (min_lng && max_lng) conditions.push(between(locations.longitude, parseFloat(min_lng), parseFloat(max_lng)));14 if (category) conditions.push(eq(locations.category, category));1516 const rows = await db.select({17 id: locations.id, name: locations.name, category: locations.category,18 latitude: locations.latitude, longitude: locations.longitude,19 address: locations.address, imageUrl: locations.imageUrl,20 }).from(locations).where(and(...conditions)).limit(500);2122 res.json(rows);23});2425// GET /api/locations/nearby — Haversine formula26router.get('/nearby', async (req, res) => {27 const { lat, lng, radius_km = 5 } = req.query;28 if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' });2930 const result = await db.execute(sql`31 SELECT id, name, category, latitude, longitude, address, image_url,32 ROUND(acos(33 sin(radians(${parseFloat(lat)})) * sin(radians(latitude)) +34 cos(radians(${parseFloat(lat)})) * cos(radians(latitude)) *35 cos(radians(longitude - ${parseFloat(lng)}))36 ) * 6371, 2) AS distance_km37 FROM locations38 WHERE is_active = true39 AND acos(40 sin(radians(${parseFloat(lat)})) * sin(radians(latitude)) +41 cos(radians(${parseFloat(lat)})) * cos(radians(latitude)) *42 cos(radians(longitude - ${parseFloat(lng)}))43 ) * 6371 < ${parseFloat(radius_km)}44 ORDER BY distance_km ASC LIMIT 5045 `);46 res.json(result.rows);47});4849// POST /api/locations/:id/favorite — toggle50router.post('/:id/favorite', async (req, res) => {51 const userId = req.user?.id;52 if (!userId) return res.status(401).json({ error: 'Login required' });53 const locationId = parseInt(req.params.id);54 const existing = await db.select().from(userFavorites)55 .where(and(eq(userFavorites.userId, userId), eq(userFavorites.locationId, locationId)));56 if (existing.length > 0) {57 await db.delete(userFavorites).where(eq(userFavorites.id, existing[0].id));58 return res.json({ favorited: false });59 }60 await db.insert(userFavorites).values({ userId, locationId });61 res.json({ favorited: true });62});6364module.exports = router;Customization ideas
Click-on-map location submission
Add a map click handler that captures the clicked lat/lng, pre-fills the Add Location form's coordinate fields, and opens a form slide-over. This makes adding locations much faster than typing coordinates manually.
Location image gallery
Add a location_images table with image_url and position columns. The sidebar panel shows images as a horizontal scroll gallery. Upload images via a POST /api/locations/:id/images route using Replit's object storage.
Route between locations
Add a 'Get Directions' button in the sidebar panel that opens the device maps app (Apple Maps or Google Maps) using the deep link format: https://maps.google.com/?q=lat,lng. No routing API needed.
Location import from CSV
Add a CSV upload endpoint that parses a spreadsheet with name, address, category, lat, lng columns and bulk-inserts locations. Useful for migrating an existing business directory.
Common pitfalls
Pitfall: Loading all locations on page load instead of using viewport queries
How to avoid: 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.
Pitfall: Forgetting the compound latitude/longitude index
How to avoid: 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.
Pitfall: Calling the viewport API on every mousemove event
How to avoid: Debounce the API call with a 300ms delay using clearTimeout/setTimeout. The fetch only fires 300ms after the user stops moving the map.
Pitfall: Storing the geocoding API key in client-side code
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a map application with Express and PostgreSQL. I have a locations table with latitude and longitude columns (numeric type). Help me write two Express route handlers: (1) a viewport bounding box query that accepts min_lat, max_lat, min_lng, max_lng as query params and returns locations within those bounds using Drizzle ORM's between() operator, with an optional category filter; and (2) a nearby search using the Haversine formula in raw SQL that accepts lat, lng, and radius_km params and returns locations sorted by distance with the calculated distance_km included in each result.
Add a heat map layer to the map application. Install the leaflet.heat npm package. Add a GET /api/locations/heatmap route that returns all active location coordinates (latitude, longitude, and an optional weight field from metadata). On the React frontend, add a toggle button between Markers view and Heat Map view. When Heat Map is active, render a Leaflet heatmap layer using L.heatLayer with the coordinates array instead of individual markers.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation