August 15, 2021 by Faisal Alghurayri
Hvalfjarðarsveit, Iceland
Building an app with geospatial capabilities is always fun!
I aim to share everything I know about developing geospatial apps by introducing the theory and the practice by building a small app.
The associated code repo is here. In addition, the live example app is here.
Like other substantial areas in computer science, there is a standard specification about communicating geospatial information. The GeoJSON spec is a 28-page reference that explains such a standard in great detail.
Understanding the specification should empower you to build geospatial apps using many languages, analytical tools, and databases.
To simplify this guide, I will cover the 20% subset of the specs that should provide 80% of the value.
type GeoJSONType = "Point" | "LineString" | "Polygon";
type Point = [longitude: number, latitude: number];
interface GeoJSON {
type: GeoJSONType;
coordinates: Point | Point[];
}
The heart of the GeoJSON spec is the above GeoJSON
object.
Each option of the GeoJSONType
is a geometry.
Let’s cover them in more detail by providing an example for each.
{
"type": "Point",
"coordinates": [1, 0]
}
The point is the most concise example for a GeoJSON object. The coordinates tuple hosts the longitude (north to south) and latitude (west to east) of a point/location. The goal is to represent any location with the most excellent precision.
{
"type": "LineString",
"coordinates": [
[1, 0],
[1, 1]
]
}
The line string takes it up a notch by listing two points in the coordinates array. The goal is to represent a line between two points. Therefore, A navigation route between two or more points is a collection of line strings.
{
"type": "Polygon",
"coordinates": [
[
[1, 0],
[1, 1],
[0, 1],
[0, 0],
[1, 0]
]
]
}
Finally, I think the polygon is one of the most powerful GeoJSON objects. The goal is to represent an area by connecting multiple line strings around it. Therefore, the first point should always match the last point to enclose an area properly.
Like web development, there is an abundance of tools to satisfy different needs. I reached the following conclusion about distinguishing between use cases:
Usually, simple use cases only need client-side tooling. However, intermediate use cases will require a full-stack solution and may need DB-level support for the
GeoJSON
standard. Finally, advanced use cases will require such specialized DB support and maybe other specialized plugins.
When building apps with map capabilities, you will need a map source and a map library.
The map source is the geographic data source that hosts the metadata about a location or an area on the map - for example, knowing the street and city names for a specific point
in the map.
The map library renders such map source and glues your application’s logic with the map - for example, showing the street and city name and allowing the user to click on a marker.
Google Maps, Mapbox, and Here maps are examples of map sources that provide map libraries. OpenStreetMaps is an example of an open-source map source. Leaflet is an example of an open-source map library. You can mix and match a map source with a map library, but it is usually better to use the complete solution.
With any of the above tooling, you should be able to:
Advanced use cases for geospatial analysis require specialized tooling. Turf.js is an example of such a tool.
The client-side tooling looks comprehensive.
However, in addition to persisting such geospatial information, the reason you may need to add server-side geospatial tooling is the exact reason why you need pagination and full-text search.
For example, if the user is browsing the map looking for real estate listings in Manhatten, New York, you need only to show the listings based on the map’s viewport. You should not dump everything to the user and rely on the client-side tool to filter out what is outside the search criteria.
There are two levels of server-side tooling:
For the simple use cases, you can use any geospatial tool that runs server-side. For the advanced use cases, most reputable DBs have built-in support for the GeoJSON
spec. If not, then such support can be provided using a plugin.
That’s a lot to digest, so let’s stop the theory and build something!
“Geospatial Applications involves the ability to integrate geography (maps) and information (data) and then access, manipulate and utilize the results via systems (computers).”
Let’s slice the above definition into more accessible terms by building an application.
Next Door App
The application we will build should allow any homeowner to list things they no longer need to sell or giveaway to their community.
The MVP should contain the following features:
Judging by our features set, I see we are going to need:
GeoJSON
spec to allow for dynamically searchingAuthentication and authorization are out of scope to keep the app concise.
I am going with React using Next JS since it is the most popular full-stack JS web framework. As for the DB, I will go with MongoDB since it has built-in support for GeoJSON
and is one of the most approachable DB options for JS developers.
Ensure you have all the prerequisites to run Next JS. I will use a free cluster from MongoDB Atlas and connect to it locally. This guide is a good reference if you need help with setting up both.
Moreover, I am going to use Mapbox map source and react-map-gl map library. You will need to have an API key (free) to have the map solution working.
With the above prerequisites met, go ahead and initialize a new Next JS application. Name the project next-door
when asked. Finally, install mongoose
, the MongoDB database driver, and react-map-gl
, our map library.
npx create-next-app
cd next-door
npm i mongoose react-map-gl
Your project structure should now look like this:
.
├── README.md
├── node_modules
│ └── ***
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ └── vercel.svg
├── pages
│ ├── _app.js
│ ├── index.js
│ └── api
└── styles
├── Home.module.css
└── globals.css
Let’s start with connecting our Next JS app with the MongoDB database.
First, we need to store our MongoDB connection string in an environment variable for good security hygiene.. And while we are at it, let’s add the Mapbox API key too.
So, create a local.env
file in the root of the project with the following snippet:
MONGODB_URI=<your MongoDB connection string>
NEXT_PUBLIC_MAPBOX_KEY=<your mapbox API key>
Then, create the following file lib/api/db.js
to host instantiating the DB client. Add the following snippet:
import mongoose from "mongoose";
const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) {
throw new Error(
"Please define the MONGODB_URI environment variable inside .env.local"
);
}
let cached = global.mongoose;
if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
export default async function dbConnect() {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
useNewUrlParser: true,
useUnifiedTopology: true,
bufferCommands: false,
bufferMaxEntries: 0,
useFindAndModify: false,
useCreateIndex: true,
};
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
return mongoose;
});
}
cached.conn = await cached.promise;
return cached.conn;
}
We are doing a verbose instantiation. This approach (maintaining a cached connection instance) is performant, especially in serverless environments like Vercel.
Now you should be good to go to the next step.
Let’s build a simple data model for the listing according to the GeoJSON
spec.
From the project’s root directory, create the following file lib/api/models/listing.js
and add the following snippet:
import mongoose from "mongoose";
const ListingSchema = new mongoose.Schema(
{
type: {
type: String,
default: "Feature",
},
properties: {
emoji: {
type: String,
required: true,
},
description: {
type: String,
},
price: {
type: Number,
default: 0,
},
},
geometry: {
type: {
type: String,
default: "Point",
},
coordinates: {
type: [Number],
index: "2dsphere",
required: true,
},
},
},
{
timestamps: true,
}
);
ListingSchema.index({ geometry: "2dsphere" });
const model =
mongoose.models.Listing || mongoose.model("Listing", ListingSchema);
export default model;
The GeoJSON
spec expects you to provide the thing you want to represent geospatially as a Feature. The shape of this feature is what we have modeled above.
For MongoDB, the key point in this model is how we set up the geometry
field in the schema. The highlight is adding the 2dsphere
indexes, which tells MongoDB to consider this as a GeoJSON
object.
The
2dsphere
is the way to tell MongoDB that we want this field to consider the special shape of the entire Earth - a sphere. If we don’t do this, all subsequent calculations will not be accurate.
On to the next step!
Let’s build a service to be responsible for interacting with the above model.
From the project’s root directory, create the following file lib/api/services/listing.js
and add the following snippet:
import Listing from "../models/listing";
import dbConnect from "../db";
export async function addListing({ lat, lng, price, description, emoji }) {
await dbConnect();
return await Listing.create({
properties: {
emoji,
description,
price: Number(price),
},
geometry: { coordinates: [lng, lat] },
});
}
export async function getListingsNearLocation(
{ lng, lat },
maxDistance = 2 * 1000 * 1.6 // 2 miles in meters
) {
await dbConnect();
return await Listing.find({
geometry: {
$near: {
$geometry: {
type: "Point",
coordinates: [lng, lat],
},
$maxDistance: maxDistance, // in meters
},
},
});
}
export async function getListingsInArea(polygon) {
await dbConnect();
return await Listing.find({
geometry: {
$geoWithin: {
$geometry: {
type: "Polygon",
coordinates: polygon,
},
},
},
});
}
The key point is how we utilized the special query keys $near
and $geoWithin
to use MongoDB’s geospatial capabilities when searching within an area or near a point! 🥳
Finally, with the model and service ready, let’s expose a few API routes.
Ideally, in production apps, you should never trust the user input. Therefore, you can properly apply a few middlewares to handle the different cases and sanitize the user input. However, such practice is out of the scope of this guide.
Let’s support adding a new listing. Create the following file pages/api/listings/index.js
and add the following snippet:
import { addListing } from "../../../lib/api/services/listings";
export default async function handler(req, res) {
try {
switch (req.method.toLowerCase()) {
case "post":
return handleAddingListing(req, res);
default:
return res.status(404).json({});
}
} catch (error) {
console.log({ error });
return res.status(500).json({});
}
}
async function handleAddingListing(req, res) {
const body = JSON.parse(req.body);
const { lng, lat, price, description, emoji } = body;
const listing = await addListing({ lat, lng, price, description, emoji });
return res.status(201).json(listing);
}
Next, let’s support searching near a given location. Create the following file pages/api/listing/near-me.js
and add the following snippet:
import { getListingsNearLocation } from "../../../lib/api/services/listings";
export default async function handler(req, res) {
try {
switch (req.method.toLowerCase()) {
case "post":
return handleGettingListingsNearMe(req, res);
default:
return res.status(404).json({});
}
} catch (error) {
console.log({ error });
return res.status(500).json({});
}
}
async function handleGettingListingsNearMe(req, res) {
const body = JSON.parse(req.body);
const { lng, lat } = body;
const listings = await getListingsNearLocation({ lng, lat });
const geoJSONResponse = {
type: "FeatureCollection",
features: listings,
};
return res.status(200).json(geoJSONResponse);
}
Finally, let’s support searching in a given area. Create the following file pages/api/listing/area.js
and add the following snippet:
import { getListingsInArea } from "../../../lib/api/services/listings";
export default async function handler(req, res) {
try {
switch (req.method.toLowerCase()) {
case "post":
return handleGettingListingsInArea(req, res);
default:
return res.status(404).json({});
}
} catch (error) {
console.log({ error });
return res.status(500).json({});
}
}
async function handleGettingListingsInArea(req, res) {
const body = JSON.parse(req.body);
const { polygon } = body;
const listings = await getListingsInArea(polygon);
const geoJSONResponse = {
type: "FeatureCollection",
features: listings,
};
return res.status(200).json(geoJSONResponse);
}
Observe the geoJSONResponse
object shape in the last two endpoints! Both are following the GeoJSON
spec when exposing geospatial APIs.
Let’s fire up Insomnia (or your preferred API testing client) to interact with our API to ensure everything is tied up.
Run the following requests in the order below. You should always get a successful response with the listing you are going to add.
Operation | Method | URL | Body |
---|---|---|---|
Add Listing | post |
/api/listings |
check below |
Get Listings Near Me | post |
/api/listings/near-me |
check below |
Get Listings in Area | post |
/api/listings/area |
check below |
Example for the body to add a listing:
{
"lng": -80.897662,
"lat": 35.484788,
"emoji": "⌨️",
"price": "99",
"description": "Almost new Apple keyboard"
}
Example for the body to get listings near me:
{
"lng": -80.897662,
"lat": 35.484788
}
Example for the body to get listings in area:
{
"polygon": [
[
[-80.919399, 35.496561],
[-80.890239, 35.495596],
[-80.889982, 35.469737],
[-80.91955, 35.47117],
[-80.919399, 35.496561]
]
]
}
This section will follow the spirit of building a PoC (proof of concept) - how fast can we show a marker and a popup on a map!
To utilize the react-map-gl library, you will need to pull from it the following three components:
Go ahead and create each one of the above components using hardcoded values from the already posted listing as follows:
The popup at lib/components/Popup.js
import { Popup as ReactMapGLPopup } from "react-map-gl";
export function Popup() {
return (
<ReactMapGLPopup longitude={-80.897662} latitude={35.484788}>
<div>
<pre>Almost new Apple keyboard</pre>
</div>
</ReactMapGLPopup>
);
}
The marker at lib/components/Marker.js
(styling is mandatory to render something on the screen)
import { Marker as ReactMapGLMarker } from "react-map-gl";
export function Marker() {
return (
<ReactMapGLMarker longitude={-80.897662} latitude={35.484788}>
<div
style={{
height: "2rem",
width: "2rem",
borderRadius: "50%",
background: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
⌨️
</div>
</ReactMapGLMarker>
);
}
And finally, the map with its children at lib/components/Map.js
import "mapbox-gl/dist/mapbox-gl.css";
import ReactMapGL from "react-map-gl";
import Marker from "./Marker";
import Popup from "./Popup";
export function Map() {
return (
<ReactMapGL
mapboxApiAccessToken={process.env.NEXT_PUBLIC_MAPBOX_KEY}
mapStyle="mapbox://styles/mapbox/streets-v11"
height="100%"
width="100%"
longitude={-80.897662}
latitude={35.484788}
zoom={13}
>
<Marker />
<Popup />
</ReactMapGL>
);
}
Then, go ahead and import the Map into pages/index.js
.
import { Map } from "../lib/components/Map";
export default function Page() {
return (
<div style={{ height: "100vh", width: "100vw" }}>
<Map />
</div>
);
}
If we wired up everything correctly, you should see this rendered on the index page
Checkpoint!
Hooray!! 🥳
Beyond this point (consuming APIs and showing a form) is idiomatic React. If you want to check the full version, which has Tailwind for CSS and XState for state management, here is the repo for the final code. If you are interested to know how I built the UI, this article goes into more detail.
I hope you found this guide helpful.
Take it easy,
~Faisal