LearnAI ToolsCareerPractice BuildsPlayContact
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.