MongoDB Geospatial Queries: Find Nearby Places in Node.js

To find nearby places in MongoDB, store points as GeoJSON [longitude, latitude], add a 2dsphere index and query with $geoNear and a radius in meters.
Since mid-2024 I have worked on the API behind NETME, a Munich meet-up app where people invite each other to a real café or bar nearby, and local venues pay for ads that show up around them. "Show me what is near me" sounds like one line of code. In practice it is an index, a coordinate order that trips everyone up once, a radius that decides who sees which ad, and a rule so one venue doesn't take every slot.
For this guide I rebuilt the pattern on MongoDB 8.2.12 with the Node.js driver 6.21 and ran every query below. The output is from that run.
Key takeaways
- Longitude comes first. GeoJSON is
[lng, lat]. Swap them and Munich ends up somewhere else, so the query quietly returns nothing. $nearand$geoNearneed a geospatial index. Without one the query fails with "unable to find index for $geoNear query".- Use
$geoNearwhen you need the distance. It sorts nearest first and writes the distance in meters into each document. - Compute distances on the server. Never trust a distance the phone sends you; send coordinates and let the database measure.
- Fair ad rotation is just a sort. Join today's view counts and sort by "seen least", then by distance.
How do you store a location in MongoDB?
Store a GeoJSON Point and index the field as 2dsphere. The sphere index measures real distances on the Earth's surface, which is what you want for anything bigger than a building.
await places.insertOne({
name: "Cafe A",
type: "cafe",
active: true,
loc: { type: "Point", coordinates: [11.5795, 48.1374] }, // [lng, lat]
});
await places.createIndex({ loc: "2dsphere" });
The index also guards your data. When I tried to insert a point with the numbers swapped and a latitude of 191, MongoDB refused it:
Can't extract geo keys: ... Longitude/latitude is out of bounds, lng: 48.1374 lat: 191
That is useful, but it only catches impossible values. A swapped pair that is still in range, like Munich's [48.1374, 11.5755], is a valid point in East Africa. It saves without complaint and never matches a search in Germany. In my test the swapped query returned 0 results. Validate the order in your API with a schema, and name your variables lng and lat so the mistake is visible in review.
What happens without an index?
Before adding the index I ran a $near query. MongoDB didn't fall back to a slow scan. It failed:
planner returned error :: caused by :: unable to find index for $geoNear query
This is a good failure because you see it in development. The quiet failure is a deploy to a fresh database where the index was created by hand once and never put in a migration. Create geospatial indexes in code at start-up or in your migration scripts, next to the schema.
How do you find places within a radius, nearest first?
$geoNear is an aggregation stage. It filters by distance, sorts nearest first and adds the distance to each result, so you can show "300 m away" without a second calculation.
const center = [11.5755, 48.1374]; // Marienplatz, Munich [lng, lat]
const nearby = await places.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: center },
distanceField: "meters",
maxDistance: 20_000, // meters, because the point is GeoJSON
query: { active: true }, // filter inside the geo stage
spherical: true,
},
},
{ $project: { _id: 0, name: 1, meters: { $round: ["$meters", 0] } } },
]).toArray();
My test data had five places: three in Munich, one paused café 200 m away and one café in Augsburg, about 55 km out. The result:
[
{ name: 'Cafe A (300 m)', meters: 297 },
{ name: 'Bar B (1.5 km)', meters: 1486 },
{ name: 'Restaurant C (8 km)', meters: 8023 }
]
The paused café is closer than everything else and still doesn't show, because the query filter runs inside the stage. Augsburg is outside the 20 km radius. The explain plan showed the GEO_NEAR_2DSPHERE stage examining 4 documents, not the whole collection.
Two rules about $geoNear catch people out:
- It must be the first stage. I put a
$matchin front of it and got: "$geoNear was not the first stage in the pipeline after optimization." Move the filter into itsqueryoption instead. - Units follow the point type. With a GeoJSON point,
maxDistanceand the distance field are in meters.
When is $geoWithin the better choice?
If you only need "inside this area" and don't care about order, use $geoWithin. It doesn't sort, and it works even without a geospatial index (it is faster with one). With $centerSphere the radius is in radians, so divide kilometers by the Earth's radius, 6378.1:
await places.find({
loc: { $geoWithin: { $centerSphere: [center, 20 / 6378.1] } },
active: true,
}).toArray();
// [ 'Restaurant C (8 km)', 'Bar B (1.5 km)', 'Cafe A (300 m)' ] <- not sorted
Same three places, in storage order. That is fine for counting venues in a city, and wrong for a list a user will scroll.
How do you rotate local ads fairly?
On NETME a partner ad only makes sense near the user, so ads are searched inside a city-sized radius. The second problem is fairness: if you always show the nearest venue, the café on the user's street wins every slot and everyone else pays for nothing. The rule we use is "show the ad this user has seen least today". The version below is my rebuild of that rule: $geoNear first, then a lookup of today's views, then a sort.
const pick = await places.aggregate([
{ $geoNear: { near: { type: "Point", coordinates: center },
distanceField: "meters", maxDistance: 20_000, query: { active: true } } },
{ $lookup: {
from: "adViews",
let: { p: "$_id" },
pipeline: [{ $match: { $expr: { $and: [
{ $eq: ["$place", "$$p"] }, { $eq: ["$user", userId] }, { $eq: ["$day", today] },
] } } }],
as: "v" } },
{ $addFields: { seen: { $ifNull: [{ $first: "$v.n" }, 0] } } },
{ $sort: { seen: 1, meters: 1 } },
{ $limit: 1 },
]).toArray();
With Cafe A seen 3 times today and Bar B once, the slot went to Restaurant C, seen 0 times. Distance only breaks ties. Two details matter in production. Count views and clicks on the server, because that is where a partner's dashboard gets its numbers and a client can't be trusted to report them. And keep an index on adViews for { user, day, place }, or the lookup becomes the slow part.
Where do the coordinates come from?
The phone sends its position, the API checks the ranges (longitude -180 to 180, latitude -90 to 90) and the database does the measuring. On NETME, distances between users are calculated on the server, not taken from the app, so a modified client can't claim to be next door. Place search itself goes through our own endpoint to the Google Places API, filtered to venue types where people actually meet, so a car park never shows up as a suggestion. Keep that call behind your API too: it hides your key and lets you cache results.
Common mistakes I see in location features
- Storing
latandlngas two number fields. You can't index that for$geoNear. Use one GeoJSON field. - Calculating distance in JavaScript after loading every row. It works with 50 venues and falls over at 50,000. Let the index do it.
- One giant radius. A city radius for ads, a few kilometers for "people near me". Different features need different numbers.
- Forgetting privacy. Show "1.5 km away", never another user's exact point. Round or blur positions before they leave the server.
Frequently asked questions
Is longitude or latitude first in MongoDB?
Longitude first. GeoJSON coordinates are [longitude, latitude], the opposite of what most map apps show. A swapped pair is often still a valid point somewhere else on Earth, so the query returns nothing instead of failing.
What is the difference between $near and $geoNear?
$near is a query operator that sorts by distance but doesn't return the distance. $geoNear is an aggregation stage that sorts, filters and writes the distance into each document. Both need a geospatial index.
What unit does maxDistance use?
Meters when the point is GeoJSON, which is what you should use. Legacy coordinate pairs use radians, which is why old examples divide by 6378.1. $centerSphere also takes radians.
Can MongoDB handle millions of locations?
Yes, with a 2dsphere index the query only looks at points near the search area. In my small test the plan examined 4 documents for 3 results. Check your own queries with explain("executionStats") as data grows.
Should I use MongoDB or PostgreSQL with PostGIS for a location app?
Both work. If your app already runs on MongoDB, its geospatial queries cover "near me", radius and polygon search well. PostGIS goes further for heavy GIS work such as routing and complex shapes. Pick the database you already run unless you need those.
Building a location-based app?
I build and maintain the API, admin panels and partner panels behind apps like NETME: geospatial search, ads, Stripe and app store payments, and real-time notifications. See my web development services, read how I make payment webhooks safe to retry and scale Socket.IO across servers, or tell me about your app and get a fixed quote.
Written by
MD Rakibul Islam Rakib
Full-stack developer, DevOps engineer and Linux system administrator with 5+ years of production experience. I deploy, harden and fix servers and web apps for clients worldwide, and everything in this article runs on real servers I manage, including this site.
- mongodb geospatial query
- mongodb $geoNear
- 2dsphere index
- find nearby places nodejs
- location based app development
- mongodb near query
- local ads radius search


