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 `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' });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/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.