LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
MongoDBIntermediate~2 hours

Store Locator API

Build a "find nearby" endpoint using a 2dsphere geospatial index.

Geospatial IndexesExpressMongoose

Overview

"Find the stores nearest to me" is not a query a normal index can answer — it needs to reason about actual physical distance across a sphere (the Earth), sorted by how close each result is, not filtered by an exact or range match on a single number. MongoDB solves this with GeoJSON: a location field stored as a Point (a [longitude, latitude] coordinate pair in a specific structured shape) combined with a 2dsphere index, which tells MongoDB to build a spatial data structure over that field instead of a normal B-tree.

By the end of this tutorial you will have a stores collection where every document's location field is a GeoJSON Point, a 2dsphere index over that field, a Mongoose schema that enforces the same shape, and an Express GET /stores/nearby endpoint that takes a longitude, latitude, and maximum distance from the request's query string and returns matching stores already sorted nearest-first.

What You'll Build
  • A stores collection with a GeoJSON Point field storing each store's coordinates.
  • A 2dsphere index over that field, required before any geospatial query will run.
  • A raw $near query returning stores within a given radius, nearest first.
  • A Mongoose schema mirroring the same GeoJSON shape, with its own 2dsphere index.
  • An Express GET /stores/nearby?lng=&lat=&maxDistance= route wrapping that query as an API.

Prerequisites

  • Basic CRUD — insertOne, find at a glance.
  • What a document and a collection are, and the general idea of an index speeding up a query.
  • Basic Mongoose — new mongoose.Schema({...}) and mongoose.model(...).
  • Basic Express — reading query-string parameters from req.query.
  • Longitude/latitude as a coordinate pair — no deeper geography knowledge required.

Project Structure

One collection, stores. Every store document carries a location field in GeoJSON Point format — a type: 'Point' plus a coordinates array of exactly two numbers, [longitude, latitude] in that order (longitude first is easy to get backwards, since most people think "latitude, longitude" by habit).

{
"_id": ObjectId("64f3c2b1a2c4d5e6f7a8b9d1"),
"name": "PrograMinds Downtown",
"address": "221 Baker Street",
"location": {
"type": "Point",
"coordinates": [-73.9857, 40.7484] // [longitude, latitude] — longitude ALWAYS comes first in GeoJSON
}
}

Step 1: Define the Store Schema

Mongoose does not have a dedicated "GeoJSON Point" field type, so the schema spells out the shape explicitly: a type string locked to 'Point' via enum, and a coordinates array of numbers. Getting this shape exactly right matters — the 2dsphere index in Step 2 expects a field that looks exactly like valid GeoJSON, and will not index a document whose location is malformed.

const mongoose = require('mongoose');
const storeSchema = new mongoose.Schema({
name: { type: String, required: true },
address: { type: String, required: true },
location: {
type: { type: String, enum: ['Point'], required: true }, // Must be the literal string 'Point' — GeoJSON requires this
coordinates: { type: [Number], required: true }, // [longitude, latitude], in that order
},
});
const Store = mongoose.model('Store', storeSchema);
module.exports = { Store };

Step 2: Create a 2dsphere Index

A 2dsphere index is what actually makes distance-based queries against location possible — without it, $near and $geoNear refuse to run at all, since there is no spatial structure for MongoDB to search. 2dsphere specifically models the Earth as a sphere and accounts for its curvature, which matters for accurate distance at any real-world scale (a flat-plane 2d index exists too, but is meant for things like game maps, not geographic coordinates).

db.stores.createIndex({ location: '2dsphere' });
// Or, equivalently, in the Mongoose schema itself:
// storeSchema.index({ location: '2dsphere' });
Confirmation

Click Run to see what this code prints.

Step 3: Seed Some Stores

Three stores at different distances from a reference point in Manhattan, so the nearest-first ordering in Step 4 has something meaningful to demonstrate.

db.stores.insertMany([
{ name: 'PrograMinds Downtown', address: '221 Baker Street', location: { type: 'Point', coordinates: [-73.9857, 40.7484] } },
{ name: 'PrograMinds Midtown', address: '350 5th Ave', location: { type: 'Point', coordinates: [-73.9878, 40.7580] } },
{ name: 'PrograMinds Brooklyn', address: '1 MetroTech Center', location: { type: 'Point', coordinates: [-73.9857, 40.6944] } },
]);

Step 4: Query Nearby Stores With $near

$near takes a $geometry point and a $maxDistance in meters, and — thanks to the 2dsphere index from Step 2 — returns every store within that radius **already sorted nearest-first**, with no separate $sort stage needed; the sort order is a built-in property of how $near uses the index.

// Stores within 5km of a point in Manhattan, nearest first
db.stores.find({
location: {
$near: {
$geometry: { type: 'Point', coordinates: [-73.9857, 40.7500] }, // The reference point to search from
$maxDistance: 5000 // Radius in meters (5km)
}
}
});
[
{ "name": "PrograMinds Downtown", "address": "221 Baker Street" },
{ "name": "PrograMinds Midtown", "address": "350 5th Ave" }
]

Step 5: An Express GET /stores/nearby Route

The route reads lng, lat, and maxDistance from the query string, converts them from strings to numbers (Express hands every query-string value over as a string, even one that looks numeric), and passes them straight into the same $near shape from Step 4 via Mongoose's .find().

const express = require('express');
const router = express.Router();
const { Store } = require('./models');
router.get('/stores/nearby', async (req, res) => {
const { lng, lat, maxDistance } = req.query; // All arrive as strings from the query string
if (!lng || !lat) {
return res.status(400).json({ error: 'lng and lat query parameters are required' });
}
const stores = await Store.find({
location: {
$near: {
$geometry: { type: 'Point', coordinates: [parseFloat(lng), parseFloat(lat)] }, // Cast strings to numbers
$maxDistance: maxDistance ? parseInt(maxDistance, 10) : 10000 // Default to a 10km radius if not provided
}
}
});
res.json(stores);
});
module.exports = router;
GET /stores/nearby?lng=-73.9857&lat=40.7500&maxDistance=5000

Click Run to see what this code prints.

Complete Code

The full schema, index, and route assembled from the steps above.

// models.js
const mongoose = require('mongoose');
const storeSchema = new mongoose.Schema({
name: { type: String, required: true },
address: { type: String, required: true },
location: {
type: { type: String, enum: ['Point'], required: true },
coordinates: { type: [Number], required: true },
},
});
storeSchema.index({ location: '2dsphere' }); // Required for $near / $geoNear to work at all
const Store = mongoose.model('Store', storeSchema);
module.exports = { Store };
// routes/stores.js
const express = require('express');
const router = express.Router();
const { Store } = require('../models');
router.get('/stores/nearby', async (req, res) => {
const { lng, lat, maxDistance } = req.query;
if (!lng || !lat) {
return res.status(400).json({ error: 'lng and lat query parameters are required' });
}
const stores = await Store.find({
location: {
$near: {
$geometry: { type: 'Point', coordinates: [parseFloat(lng), parseFloat(lat)] },
$maxDistance: maxDistance ? parseInt(maxDistance, 10) : 10000
}
}
});
res.json(stores);
});
module.exports = router;

Sample Queries

Two more geospatial operations a real store locator would need: computing the actual distance to each result with $geoNear, and finding every store inside a drawn boundary instead of a simple radius.

// $geoNear (used in an aggregation pipeline) attaches the computed distance to each result,
// which plain $near cannot do — useful for showing "2.3 km away" next to each store.
db.stores.aggregate([
{
$geoNear: {
near: { type: 'Point', coordinates: [-73.9857, 40.7500] },
distanceField: 'distanceInMeters', // New field added to each output document
maxDistance: 5000,
spherical: true // Required alongside a 2dsphere index
}
}
]);
[
{ "name": "PrograMinds Downtown", "distanceInMeters": 372.4 },
{ "name": "PrograMinds Midtown", "distanceInMeters": 1189.7 }
]
// Stores inside a drawn polygon boundary (e.g. a delivery zone), instead of a simple radius
db.stores.find({
location: {
$geoWithin: {
$geometry: {
type: 'Polygon',
coordinates: [[[-74.01, 40.70], [-73.96, 40.70], [-73.96, 40.78], [-74.01, 40.78], [-74.01, 40.70]]] // Closed ring
}
}
}
});
[
{ "name": "PrograMinds Downtown" },
{ "name": "PrograMinds Midtown" }
]

Extend This Project

  • Add store hours and filter /stores/nearby results down to stores currently open.
  • Add a radius slider on a map-based frontend that adjusts maxDistance live.
  • Combine $geoNear with $match to find only nearby stores that also have a specific product in stock.
  • Add reverse geocoding so a user can search by address instead of raw coordinates.
  • Cache frequent /stores/nearby lookups for popular coordinates to reduce repeated geospatial queries.

Summary

You stored each store's coordinates as a GeoJSON Point, built a 2dsphere index over that field — without which no geospatial query can run at all — and used $near to return nearby stores already sorted by distance, no manual sort step required. The Express route wrapped that query as a small, real "find nearby" API, and $geoNear/$geoWithin in the Sample Queries section showed the two other shapes a real store locator needs: an actual distance value per result, and matching against an arbitrary drawn boundary instead of a simple radius.