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.
- A
storescollection with a GeoJSONPointfield storing each store's coordinates. - A
2dsphereindex over that field, required before any geospatial query will run. - A raw
$nearquery returning stores within a given radius, nearest first. - A Mongoose schema mirroring the same GeoJSON shape, with its own
2dsphereindex. - An Express
GET /stores/nearby?lng=&lat=&maxDistance=route wrapping that query as an API.
Prerequisites
- Basic CRUD —
insertOne,findat 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({...})andmongoose.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' });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 firstdb.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;Click Run to see what this code prints.
Complete Code
The full schema, index, and route assembled from the steps above.
// models.jsconst 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.jsconst 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 radiusdb.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/nearbyresults down to stores currently open. - Add a
radiusslider on a map-based frontend that adjustsmaxDistancelive. - Combine
$geoNearwith$matchto 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/nearbylookups 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.