Skip to main content
RapidDev - Software Development Agency

How to Build a Map Application with Replit

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'll build

  • Full-screen interactive map with OpenStreetMap tiles using react-leaflet (no API key required)
  • Category-colored markers that cluster when zoomed out using react-leaflet-cluster
  • Viewport-based marker loading: only locations within the current map bounds are fetched, keeping performance fast
  • Haversine nearby search returning locations within a radius in kilometers
  • Sidebar panel showing location details (name, description, address, image, metadata) when a marker is clicked
  • Search bar with geocoding using OpenCage or Mapbox API for address-to-coordinates lookup
  • User favorites toggle with Replit Auth, and an Add Location form with click-on-map coordinate selection
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read1-2 hoursReplit FreeApril 2026RapidDev Engineering Team
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.

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
react-leafletInteractive Map
Replit AuthAuth

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

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.

prompt.txt
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 0
10// - 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/:id
19// 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/categories
24// 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.

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.

server/routes/locations.js
1const express = require('express');
2const { db } = require('../db');
3const { locations, locationCategories } = require('../../shared/schema');
4const { eq, and, gte, lte, between } = require('drizzle-orm');
5
6const router = express.Router();
7
8// GET /api/locations?min_lat=40.0&max_lat=41.0&min_lng=-74.5&max_lng=-73.5&category=restaurant
9router.get('/', async (req, res) => {
10 const { min_lat, max_lat, min_lng, max_lng, category } = req.query;
11
12 const conditions = [eq(locations.isActive, true)];
13
14 // Viewport bounding box filter
15 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 }
24
25 const rows = await db
26 .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 overload
38
39 res.json(rows);
40});
41
42// GET /api/locations/:id — detail with full metadata
43router.get('/:id', async (req, res) => {
44 const [location] = await db
45 .select()
46 .from(locations)
47 .where(eq(locations.id, parseInt(req.params.id)));
48
49 if (!location) return res.status(404).json({ error: 'Location not found' });
50 res.json(location);
51});
52
53module.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.

server/routes/nearby.js
1const { sql } = require('drizzle-orm');
2
3// GET /api/locations/nearby?lat=40.7128&lng=-74.0060&radius_km=5
4router.get('/nearby', async (req, res) => {
5 const { lat, lng, radius_km = 5 } = req.query;
6
7 if (!lat || !lng) {
8 return res.status(400).json({ error: 'lat and lng query params are required' });
9 }
10
11 const latNum = parseFloat(lat);
12 const lngNum = parseFloat(lng);
13 const radiusNum = parseFloat(radius_km);
14
15 // Haversine formula in PostgreSQL
16 // Returns distance in km using Earth radius 6371
17 const nearby = await db.execute(sql`
18 SELECT
19 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 ) * 6371
26 , 2) AS distance_km
27 FROM locations
28 WHERE is_active = true
29 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 ASC
35 LIMIT 50
36 `);
37
38 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.

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.

server/routes/geocode.js
1const axios = require('axios');
2const { userFavorites } = require('../../shared/schema');
3
4// GET /api/geocode?address=1600+Pennsylvania+Ave+Washington+DC
5router.get('/geocode', async (req, res) => {
6 const { address } = req.query;
7 if (!address) return res.status(400).json({ error: 'address param required' });
8
9 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 }
13
14 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});
28
29// POST /api/locations/:id/favorite — toggle
30router.post('/:id/favorite', async (req, res) => {
31 const userId = req.user?.id;
32 if (!userId) return res.status(401).json({ error: 'Login required' });
33
34 const locationId = parseInt(req.params.id);
35
36 const existing = await db
37 .select()
38 .from(userFavorites)
39 .where(and(eq(userFavorites.userId, userId), eq(userFavorites.locationId, locationId)));
40
41 if (existing.length > 0) {
42 await db.delete(userFavorites).where(eq(userFavorites.id, existing[0].id));
43 return res.json({ favorited: false });
44 }
45
46 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.

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
1// client/src/components/Map.jsx — React Leaflet map with viewport loading
2// Install: npm install react-leaflet leaflet react-leaflet-cluster
3
4import { 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';
8
9function MapEventHandler({ onBoundsChange }) {
10 const map = useMapEvents({
11 moveend: () => onBoundsChange(map.getBounds()),
12 zoomend: () => onBoundsChange(map.getBounds()),
13 });
14 return null;
15}
16
17export function MapView({ selectedCategory }) {
18 const [markers, setMarkers] = useState([]);
19 const [selectedLocation, setSelectedLocation] = useState(null);
20
21 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]);
33
34 // Debounce map movement to avoid hammering the API
35 let debounceTimer;
36 const handleBoundsChange = (bounds) => {
37 clearTimeout(debounceTimer);
38 debounceTimer = setTimeout(() => fetchMarkers(bounds), 300);
39 };
40
41 return (
42 <MapContainer center={[40.7128, -74.006]} zoom={13} style={{ height: '100vh', width: '100%' }}>
43 <TileLayer
44 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
45 attribution='&copy; OpenStreetMap contributors'
46 />
47 <MapEventHandler onBoundsChange={handleBoundsChange} />
48 <MarkerClusterGroup>
49 {markers.map(loc => (
50 <Marker
51 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

server/routes/locations.js
1const express = require('express');
2const { db } = require('../db');
3const { locations, userFavorites } = require('../../shared/schema');
4const { eq, and, between, sql } = require('drizzle-orm');
5
6const router = express.Router();
7
8// GET /api/locations — viewport bounding box query
9router.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));
15
16 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);
21
22 res.json(rows);
23});
24
25// GET /api/locations/nearby — Haversine formula
26router.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' });
29
30 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_km
37 FROM locations
38 WHERE is_active = true
39 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 50
45 `);
46 res.json(result.rows);
47});
48
49// POST /api/locations/:id/favorite — toggle
50router.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});
63
64module.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.

ChatGPT Prompt

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.

Build Prompt

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.

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.