API REST construida con Node.js, Express y MongoDB para aprender a trabajar con datos de geolocalización usando Mongoose y las capacidades espaciales de MongoDB.
Este proyecto sirve como referencia para entender cómo almacenar coordenadas geográficas en una base de datos y cómo hacer consultas de proximidad del tipo "restaurantes cerca de mí" o "restaurantes dentro de un radio".
- Conceptos clave: GeoJSON en MongoDB
- Definir un campo GeoJSON en Mongoose
- El índice 2dsphere
- Consulta $near
- Consulta $geoWithin + $centerSphere
- Errores comunes
- Instalación y uso
- Endpoints de la API
GeoJSON es un formato estándar para representar datos geográficos en JSON. MongoDB lo soporta de forma nativa, lo que nos permite guardar coordenadas y hacer consultas espaciales directamente desde Mongoose.
El tipo más común es Point (un punto en el mapa), que se define así:
{
"type": "Point",
"coordinates": [-3.7038, 40.4168]
}Este es el detalle mas importante (y el error mas frecuente):
coordinates: [longitud, latitud]
- Longitud = eje X (cuanto hacia el este u oeste). Madrid:
-3.7038 - Latitud = eje Y (cuanto hacia el norte o sur). Madrid:
40.4168
En el dia a dia estamos acostumbrados a decir "latitud, longitud" (como en Google Maps), pero MongoDB usa el orden matematico inverso: longitud primero. Si lo confundes, tus puntos apareceran en el oceano.
Asi se define un campo de tipo GeoJSON Point en un schema de Mongoose (/models/restaurant.model.js):
const { Schema, model } = require('mongoose')
const restaurantSchema = new Schema(
{
name: {
type: String,
required: [true, 'El nombre del restaurante es obligatorio'],
trim: true,
},
cuisine: {
type: String,
required: [true, 'El tipo de cocina es obligatorio'],
trim: true,
},
address: {
type: String,
trim: true,
},
// Campo GeoJSON Point para almacenar la ubicacion del restaurante
location: {
type: {
type: String,
enum: ['Point'], // Solo permitimos el tipo "Point"
required: true,
},
coordinates: {
type: [Number], // Array de numeros: [longitud, latitud]
required: true,
},
},
},
{
timestamps: true,
}
)
// El indice 2dsphere es OBLIGATORIO para que las consultas geo funcionen
restaurantSchema.index({ location: '2dsphere' })
const Restaurant = model('Restaurant', restaurantSchema)
module.exports = RestaurantPuntos clave del schema:
- El campo
typedentro delocationdebe ser siempre'Point'(usamosenumpara forzarlo). coordinateses un array de dos numeros:[longitud, latitud].- La linea
restaurantSchema.index({ location: '2dsphere' })es imprescindible — la explicamos en la siguiente seccion.
MongoDB necesita un indice especial llamado 2dsphere para poder ejecutar consultas geograficas. Sin el, cualquier consulta con $near o $geoWithin fallara con un error.
restaurantSchema.index({ location: '2dsphere' })Las consultas geograficas son computacionalmente costosas: MongoDB tiene que calcular distancias entre puntos usando formulas trigonometricas sobre una esfera (la Tierra). El indice 2dsphere pre-organiza los datos en una estructura espacial (similar a como un indice normal organiza texto) para que estas consultas sean eficientes.
Sin el indice, MongoDB tendria que recorrer TODOS los documentos de la coleccion para calcular la distancia a cada uno. Con millones de restaurantes, eso seria inviable.
El indice
2dspherele dice a MongoDB: "Este campo contiene coordenadas geograficas sobre una esfera. Organizalos de forma que pueda encontrar puntos cercanos rapidamente."
$near busca documentos ordenados por distancia (del mas cercano al mas lejano) desde un punto de referencia.
Restaurant.find({
location: {
$near: {
$geometry: {
type: 'Point',
coordinates: [longitud, latitud], // Punto de referencia
},
$maxDistance: 1000, // Radio maximo en METROS
},
},
})$geometry: el punto desde el que mides la distancia (en formato GeoJSON).$maxDistance: distancia maxima en metros. Si lo omites, devuelve todos los documentos ordenados por distancia (puede ser muy lento).- Los resultados vienen ordenados automaticamente de mas cercano a mas lejano.
// En restaurants.controller.js
const { lat, lng, maxDistance } = req.query
const filter = {}
if (lat && lng && maxDistance) {
filter.location = {
$near: {
$geometry: {
type: 'Point',
coordinates: [parseFloat(lng), parseFloat(lat)], // OJO: lng primero
},
$maxDistance: parseFloat(maxDistance),
},
}
}
const restaurants = await Restaurant.find(filter)Buscar restaurantes a menos de 1km del centro de Madrid (Puerta del Sol):
curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&maxDistance=1000"$geoWithin busca documentos cuya ubicacion este dentro de una forma geometrica. Combinado con $centerSphere, define un circulo sobre la esfera terrestre.
| Caracteristica | $near |
$geoWithin |
|---|---|---|
| Ordena por distancia | Si | No |
| Requiere indice 2dsphere | Si | No (pero es recomendable) |
| Uso en aggregation | No | Si |
| Resultado | Mas cercano primero | Sin orden garantizado |
Restaurant.find({
location: {
$geoWithin: {
$centerSphere: [
[longitud, latitud], // Centro del circulo
radio_en_radianes, // Radio en RADIANES (no metros)
],
},
},
})Este es el punto donde muchos estudiantes se confunden. $centerSphere espera el radio en radianes, no en metros.
La formula es:
radianes = metros / 6378100
Donde 6378100 es el radio de la Tierra en metros.
const radiusInRadians = parseFloat(radius) / 6378100Por ejemplo, para un radio de 2km (2000 metros):
2000 / 6378100 ≈ 0.000313706 radianes
// En restaurants.controller.js
const { lat, lng, radius } = req.query
const filter = {}
if (lat && lng && radius) {
const radiusInRadians = parseFloat(radius) / 6378100 // Convertir metros a radianes
filter.location = {
$geoWithin: {
$centerSphere: [
[parseFloat(lng), parseFloat(lat)], // OJO: lng primero
radiusInRadians,
],
},
}
}
const restaurants = await Restaurant.find(filter)Buscar restaurantes dentro de un radio de 2km del centro de Madrid:
curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&radius=2000"Todos los filtros son combinables. Por ejemplo, restaurantes italianos dentro de 3km:
curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&radius=3000&cuisine=Italian"Problema: Poner latitud antes que longitud.
// MAL - latitud, longitud (orden de Google Maps)
coordinates: [40.4168, -3.7038]
// BIEN - longitud, latitud (orden de MongoDB/GeoJSON)
coordinates: [-3.7038, 40.4168]Como detectarlo: Tus restaurantes apareceran ubicados en el mar o en lugares imposibles (p.ej. en el Oceano Atlantico en lugar de en Madrid).
Problema: No definir el indice en el schema.
Error que veras en la consola:
MongoServerError: error processing query: ns=geo-api.restaurants
Tree: GEONEAR field=location maxdist=1000 isNearSphere=0
planner returned error :: caused by :: unable to find index for $geoNear query
Solucion: Anadir la linea del indice en el schema:
restaurantSchema.index({ location: '2dsphere' })Si ya tienes datos en la coleccion cuando anadir el indice, MongoDB lo construira automaticamente sobre los documentos existentes.
Problema: Intentar usar $near dentro de un pipeline de aggregation ($match).
// MAL - $near NO funciona en aggregation
Restaurant.aggregate([
{ $match: { location: { $near: { ... } } } }
])Error que veras:
MongoServerError: $near is not allowed inside of $match aggregation operator
Solucion: Para busquedas de proximidad en aggregation, usa $geoNear como primer stage del pipeline:
// BIEN - usar $geoNear en aggregation
Restaurant.aggregate([
{
$geoNear: {
near: { type: 'Point', coordinates: [lng, lat] },
distanceField: 'distance',
maxDistance: 1000,
spherical: true,
},
},
])
// O simplemente usa find() con $near si no necesitas aggregation
Restaurant.find({ location: { $near: { ... } } })Problema: Pasar metros directamente a $centerSphere sin convertir.
// MAL - metros directamente
$centerSphere: [[-3.7038, 40.4168], 2000]
// BIEN - convertir a radianes
$centerSphere: [[-3.7038, 40.4168], 2000 / 6378100]Como detectarlo: Los resultados incluiran restaurantes que estan claramente fuera del radio esperado (o no devolvera ningun resultado porque el radio resultante es enorme).
- Node.js v18 o superior
- MongoDB (local o Atlas)
- Un cliente REST (curl, Postman, Insomnia...)
1. Instalar dependencias
npm install2. Configurar variables de entorno
Crea un archivo .env en la raiz del proyecto:
# .env
MONGODB_URI=mongodb://localhost:27017/geo-api
PORT=5005
CORS_ORIGIN=http://localhost:5173Si usas MongoDB Atlas, sustituye MONGODB_URI por tu connection string de Atlas.
3. Poblar la base de datos con datos de prueba
npm run seedEste comando crea 20 restaurantes distribuidos por el centro de Madrid con coordenadas reales. Puedes ejecutarlo varias veces — primero borra los datos existentes y luego los vuelve a crear.
4. Arrancar el servidor en modo desarrollo
npm run devEl servidor arrancara en http://localhost:5005.
POST /api/restaurantscurl -X POST http://localhost:5005/api/restaurants \
-H "Content-Type: application/json" \
-d '{
"name": "Casa Lucio",
"cuisine": "Spanish",
"address": "Calle Cava Baja, 35, Madrid",
"location": {
"type": "Point",
"coordinates": [-3.7089, 40.4133]
}
}'GET /api/restaurants# Sin filtros - devuelve todos
curl http://localhost:5005/api/restaurants
# Filtrar por cocina
curl "http://localhost:5005/api/restaurants?cuisine=Italian"
# $near - restaurantes a menos de 1km (ordenados por distancia)
curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&maxDistance=1000"
# $geoWithin - restaurantes dentro de 2km (sin ordenar por distancia)
curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&radius=2000"
# Combinar geo + cocina
curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&radius=3000&cuisine=Japanese"GET /api/restaurants/:idcurl http://localhost:5005/api/restaurants/64a1b2c3d4e5f6789012345aPATCH /api/restaurants/:idcurl -X PATCH http://localhost:5005/api/restaurants/64a1b2c3d4e5f6789012345a \
-H "Content-Type: application/json" \
-d '{
"name": "Casa Lucio (Actualizado)",
"cuisine": "Spanish Traditional"
}'DELETE /api/restaurants/:idcurl -X DELETE http://localhost:5005/api/restaurants/64a1b2c3d4e5f6789012345ageo-api/
├── app.js # Configuracion de Express
├── package.json
├── .env # Variables de entorno (no subir a Git)
├── .gitignore
├── README.md
├── bin/
│ └── seeds.js # Script para poblar la base de datos
├── config/
│ ├── db.config.js # Conexion a MongoDB
│ └── routes.config.js # Registro de rutas
├── controllers/
│ └── restaurants.controller.js # Logica de negocio y consultas geo
├── middlewares/
│ ├── cors.middleware.js # Configuracion de CORS
│ ├── clearbody.middleware.js # Limpieza de campos vacios en req.body
│ └── errors.middleware.js # Manejo centralizado de errores
└── models/
└── restaurant.model.js # Schema con campo GeoJSON + indice 2dsphere
- MongoDB GeoJSON Objects
- MongoDB $near operator
- MongoDB $geoWithin operator
- MongoDB 2dsphere index
- Mongoose Schema Types
Happy coding!