frontend
Create an Asset Tracker with Next.js and React Leaflet
Incremental static regeneration makes updating static pages easy.
Introduction
Recently I began working for an Internet of Things startup, Blues Wireless that aims to make IoT development easier - even when reliable Internet connections are not available. Blues does this via Notecards - prepaid cellular devices that can be embedded into any IoT device "on the edge" to transmit sensor data as JSON to a secure cloud: Notehub.
Since web development is my main area of expertise (not IoT development), I started off building an easier IoT project: an asset tracker using just a Blues Notecard, Blues Notecarrier AL with a built-in GPS antenna, and a small lithium-ion polymer (LiPo) battery.
With the help of the Blues developer experience documentation, I had GPS location data being delivered to the Notehub cloud in short order. That's cool and all, but the way that data from sensors in the world really becomes useful is when it's displayed to users in some sort of UI, right? It could be charts, tables, or in my case, a map.
So I wanted to take my data from the Notehub cloud and put it into a custom-made dashboard to track and display the Notecard's location in the real world. Since React is my current JavaScript framework of choice, I decided to build a Next.js- TypeScript-powered dashboard, and I learned a ton of stuff in the process, which I intend to share with you over a series of blog posts in the next few months.
In this article, I'm going to show you how to add a map to a Next.js application, pull in location data from a third-party API source, and regularly revalidate the data to update the map when new location data is present.
Here's what the final dashboard looks like - the map is the focus for this particular post:
Set up a map component in Next.js app
NOTE: This article will not go through the initial setup of a brand new Next.js app - that's outside the scope of this post. If you're starting from scratch, I would recommend following the Next.js starter app with TypeScript documentation.
If you'd prefer, you can also fork and download my whole, working code from the GitHub repo here.
Install map project dependencies
The first thing to do in this post, is add a map to a Next project. This is going to require a few new npm packages added to our project: leaflet, react-leaflet and leaflet-defaulticon-compatibility.
$ npm install leaflet react-leaflet leaflet-defaulticon-compatibility
NOTE: You'll also need
react
andreact-dom
as peer dependencies if they're not already in your project, too.
-
leaflet is our base, JavaScript library for interactive maps - it provides the basis upon which our other packages depend.
-
react-leaflet provides easier-to-use React components for Leaflet maps. It provides bindings between React and Leaflet, not replacing Leaflet, but leveraging it to abstract Leaflet layers as React components. If you're curious to learn more about React Leaflet, I recommend perusing the documentation.
-
leaflet-defaulticon-compatibility retrieves all Leaflet Default Icon options from CSS, in particular all icon images URL's, to improve compatibility with bundlers and frameworks that modify URL's in CSS.
Build engines and frameworks that modify URLs in CSS, can often conflict with Leaflet built-in Default Icon images automatic management, and this package helps handle it.
TypeScript Note:
If you're using TypeScript in your project, you'll also want to want to install the follow dev dependency to avoid TypeScript errors:
$ npm install @types/leaflet --save-dev
With new map libraries installed, it's time to move on to configuring the project to use these new resources.
Generate a Mapbox token for the map's display style and add it to the project
For the map display that the asset tracker will be on, I chose to use Mapbox styles. It's got a lot of nice map display styles to choose from, and developers can create their own Mapbox API tokens to access these styles by signing up for a free Mapbox account.
After you've signed up and created a new API token, copy the token value - it will be used in the Next.js app. In the Next.js app's next.config.js
file at the root of the project, add the API token like so:
next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
env: {
MAPBOX_ACCESS_TOKEN:
"[MAPBOX_TOKEN]",
},
};
From this file, Next can access the token when it needs to call the Mapbox API endpoint. Next, we'll create the <Map />
component in our project.
Create the <Map>
component
As this is a React project, individual, reusable components are how I like to roll, so inside of the project, create a new file named Map.tsx
and paste in the following code. The live code is available by clicking the file title below.
import {
MapContainer,
TileLayer,
Marker,
Popup,
GeoJSON,
CircleMarker,
} from "react-leaflet";
import "leaflet/dist/leaflet.css";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "leaflet-defaulticon-compatibility";
const Map = ({
coords,
lastPosition,
markers,
latestTimestamp,
}: {
coords: number[][];
lastPosition: [number, number];
markers: [number, number][];
latestTimestamp: string;
}) => {
const geoJsonObj: any = [
{
type: "LineString",
coordinates: coords,
},
];
const mapMarkers = markers.map((latLng, i) => (
<CircleMarker key={i} center={latLng} fillColor="navy" />
));
return (
<>
<h2>Asset Tracker Map</h2>
<MapContainer
center={lastPosition}
zoom={12}
style={{ height: "100%", width: "100%" }}
>
<TileLayer
url={`https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${process.env.MAPBOX_ACCESS_TOKEN}`}
/>
<Marker position={lastPosition} draggable={true}>
<Popup>
Last recorded position:
<br />
{lastPosition[0].toFixed(3)}°,
{lastPosition[1].toFixed(3)}°
<br />
{latestTimestamp}
</Popup>
<GeoJSON data={geoJsonObj}></GeoJSON>
{mapMarkers}
</Marker>
</MapContainer>
</>
);
};
export default Map;
Now let's talk about everything that's going on in this component.
At the top of the file:
- all the individual React Leaflet components needed for this component are imported,
- the original Leaflet CSS is imported,
- and the Leaflet Default Icon Compatibility CSS and JS are imported afterwards - as specified by the usage instructions.
After that, are the props that this component accepts:
coords
- a list of arrays that have GPS longitude and latitude in them - this draws the connecting lines between coordinates.lastPosition
- the most recent GPS latitude and longitude to display in the popup when the user clicks the icon on the map.markers
- another list of arrays that have GPS latitude and longitude (yes, the order of coordinates is reversed in these arrays) to display the blue circles of previous places on the map the tracker was.latestTimestamp
- the most recent timestamp of GPS coordinates received (also for displaying in the popup on the map).
Let's skip down to the JSX.
<MapContainer />
is the component responsible for creating the Leaflet Map instance and providing it to its child components - without this component, the map won't work. In this component we can define the map's center
coordinates, its default zoom level on the map, and some basic styling for the component to display properly.
The <TileLayer />
component is where our Mapbox style and newly generated API token come into play. Just choose whatever style suits your fancy, replace the streets-v11
portion of the string with it, and make sure the Mapbox token is present in the next.config.js
file, which I showed in the previous step. Without this component there's no map background for the coordinates to render on - instead it will just be a blank canvas.
<Marker />
takes in the lastPosition
prop to display the icon on the map of the tracker's last recorded position, and it wraps the <Popup />
component, the <GeoJSON />
component, and the list of <CircleMarker />
components.
The <Popup />
component is a nicely-styled tooltip that can display whatever info is desired. My <Popup />
shows the tracker's last GPS coordinates and time it was reported when a user clicks on it, but it can display anything you want.
The <GeoJson />
component is where the coords
list of GPS longitude and latitude arrays are passed in to draw the connecting lines between coordinates. The type: "LineString"
in the geoJsonObj
where the coordinates are fed in, is what handles it.
And last but not least, the <CircleMarker >/
components, which are displayed in this component's JSX as {mapMarkers}
.
NOTE: In order to get all the
markers
in the list to render as individual circles on the map, I had to create this little function to iterate over the list and generate all the circles, then inject that directly into the JSX.Trying to iterate over all the values inside the JSX wouldn't work, which I believe is an example of how this
react-leaflet
package behaves differently from traditional React code.
And that's all that's going on in this component, not so complicated when it's broken down into the individual pieces that make it up, right?
Render the map in a Next.js app
Our final step to get the map rendering inside of a Next app: importing the component with the option ssr:false
.
Since the react-leaflet
library only works in the browser, we have to use Next.js's dynamic import()
support with no SSR to tell the map component to only render after the Next.js server-side rendering has happened.
So wherever this <Map />
component is being injected into your app, use the syntax detailed below. In my app, it's in the index.tsx
page file, and I've condensed the code in the file down for clarity. Click on the file title to see the full code.
// imports
import dynamic from "next/dynamic";
// other imports
type dataProps = {
// condensed for code brevity
};
export default function Home({ data }: { data: dataProps[] }) {
// needed to make the Leaflet map render correctly
const MapWithNoSSR = dynamic(() => import("../src/components/Map"), {
ssr: false,
});
// logic to transform data into the items needed to pass to the map
return (
<div>
{/* extra tracker app code */}
<main>
<h1>React Blues Wireless Asset Tracker</h1>
{/* other tracker components */}
<div>
<MapWithNoSSR
coords={lngLatCoords}
lastPosition={lastPosition}
markers={latLngMarkerPositions}
latestTimestamp={latestTimestamp}
/>
</div>
{/* other tracker components */}
</main>
</div>
);
}
// more code down here: getStaticProps
Once the <Map />
has been dynamically imported with server-side rendering disabled, the component can be used just like any other in the application. Simple as that.
At this point, feel free to mock some hard coded data for the map to make sure it's working correctly. In the next section, I'll cover pulling in live data from the Notehub cloud were my asset tracker location data currently lives.
Pull in the data for the map
Ok, so the map is set up in the app, now it's time to give it some data to display. If you'd like to build your own asset tracker like I did, you're welcome to - I'll briefly outline the hardware I used and point you towards the documentation to get it configured - the very same documentation I used to set mine up.
IoT Hardware list
Here's the equipment you'll need to make this project happen:
- Blues Wireless NB-IoT & LTE-M Notecard Global
- Blues Wireless Notecarrier AL with LiPo battery connector
- LiPo battery from an IoT supplier like Adafruit (mine is the 3.7v 2500mAh version)
Initial Notecard and Notecarrier AL configuration
Follow the Blues quickstart guide to set up the Notecard, Notecarrier, and first Notehub project, and the Blues asset tracking guide for the commands needed to configure a Notecard for GPS location tracking.
In addition to these instructions, there's an important caveat to make this asset tracker work for our purposes.
- In the final configuration step:
card.location.track
where the tracker starts running, include the property of:"sync" : true
. This property means as soon as a new event is acquired by the Notecard (a new GPS location, in this case), the Notecard will sync the event to Notehub instead of waiting for its regularly scheduledoutbound
time.
If you're curious, here's all of the commands I used to set up my Notecard from start to finish using the built-in web REPL on the Blues developer experience site.
$ {"req":"card.restore","delete":true}
#factory reset Notecard
$ {"req":"hub.set","product":"com.blues.[NOTEHUB_PROJECT_ID_HERE]","mode":"periodic","outbound":10,"inbound":60}
#attach tracker to Notehub project, set it to periodic mode,
#sync outbound requests from the Notecard every 10 mins and inbound reqs from Notehub every 60 mins
$ {"req":"card.location.mode","mode":"periodic","seconds":360}
#tell card how often to get GPS reading and only when motion is detected
$ {"req":"card.location.track","start":true,"heartbeat":true,"hours":12,"sync":true}
#start tracking, issue heartbeat every 12 hours when no motion detected,
#sync data with Notehub as soon as a tracking event is acquired (this is an important one)
Use the Notehub API to fetch the tracker data into Next.js
The Notehub API can be used to fetch events (the data containing Notecard GPS coordinates) directly from Notehub. The Notehub API requires users to create an authorization token to be passed along with requests, but the process is well documented, as is the API to fetch all events.
Generate a Notehub auth token
Below is the code to run in the command line to generate the Notehub authorization token:
$ curl -X POST
-L 'https://api.notefile.net/auth/login'
-d '{"username":"[[email protected]]", "password": "[your_password]"}'
Copy this token and inside of your Next.js project, at the root of the project, create a .env.local
file - this is where sensitive info will be kept: secrets, project info, and anything else you'd rather not commit to GitHub for all the world to see. Here's an example of what the file should look like, and the two secret variables it needs:
NOTEHUB_PROJECT_ID=APP_ID_GOES_HERE # get this from Notehub
NOTEHUB_TOKEN=NOTEHUB_GENERATED_TOKEN_GOES_HERE # paste in token generated in previous step
This
.env.local
file is how Next.js automatically reads in environment variables used at build time or on the client side. All the variables you'll need are build time variables so none of them need to be prefixed withNEXT_PUBLIC_
, which allows for variable access on the client side.
Create a fetchNotecardData()
function in Next.js
With our Notehub token and project ID specified in the .env.local
file, now we can make our connection to Notehub via Next's getStaticProps
function.
Create a new file named something like notecardData.ts
, this is where the function to fetch data from Notehub and filter down to the events we want - the _track.qo
events - will live.
Here is what the code to fetch events will look like - click on the file name to see the code in my actual repo.
export async function fetchNotecardData() {
interface dataProps {
[file: string]: any;
}
let eventArray: object[] = [];
const baseUrl = `https://api.notefile.net/v1/projects/${process.env.NOTEHUB_PROJECT_ID}/events`;
const headers = {
"Content-Type": "application/json",
"X-SESSION-TOKEN": `${process.env.NOTEHUB_TOKEN}`,
};
const res = await fetch(fullUrl, {
headers: headers,
});
const eventData = await res.json();
eventArray = eventData.events;
while (eventData.has_more) {
const res = await fetch(`${baseUrl}?since=${eventData.through}`, {
headers: headers,
});
const newEventData = await res.json();
eventArray = [...eventArray, ...newEventData.events];
if (newEventData.has_more) {
eventData.through = newEventData.through;
} else {
eventData.has_more = false;
}
}
const filteredEvents = eventArray.filter(
(event: dataProps) => event.file === "_track.qo"
);
return filteredEvents;
}
Although this file looks verbose at first glance, it's not actually that complex.
It starts off setting up a baseUrl
that defines the URL connection to Notehub events - this is where access the NOTEHUB_PROJECT_ID
environment variable comes into play.
Then the header
object containing the "X-SESSION-TOKEN"
, which is set equal to our generated NOTEHUB_TOKEN
, is up next.
After that, the Notehub event endpoint is hit, and it will automatically pull back the first 50 events it has stored, and if there's more events than what was just returned, the JSON response list will also include the properties through
and has_more
.
If has_more
exists, this while
loop function will keep hitting Notehub using the through
value (the globally-unique identifier of the last event in the array of returned events), until there are no more events to add to the eventArray
list.
Finally, all of the events that have been gathered up are filtered down to only the _track.qo
type, because those are the events that contain the tracker's GPS coordinates.
Call the fetchNotecardData()
function on the page
With our function to connect to Notehub and get event data constructed, it's time to make that call in the page where the <Map />
component is located. To do this, we'll be using Next's getStaticProps
function to fetch the data server-side.
Inside of the index.tsx
file where we previously imported the <Map />
component, we'll add the following code down at the bottom of the file. I've condensed the rest of the file for clarity, but the full file is linked below.
// imports
import { GetStaticProps } from "next";
import { fetchNotecardData } from "../src/lib/notecardData";
// other imports
type dataProps = {
// condensed for code brevity
};
export default function Home({ data }: { data: dataProps[] }) {
// map component imported dynamically here
// logic to transform data into the items needed to pass to the map
return (
<div>
{/* extra tracker app code */}
<main>
<h1>React Blues Wireless Asset Tracker</h1>
{/* other tracker components */}
<div>
<MapWithNoSSR
coords={lngLatCoords}
lastPosition={lastPosition}
markers={latLngMarkerPositions}
latestTimestamp={latestTimestamp}
/>
</div>
{/* other tracker components */}
</main>
</div>
);
}
export const getStaticProps: GetStaticProps = async () => {
const data = await fetchNotecardData();
return { props: { data } };
};
To call the Notehub API in the index.tsx
file on the server-side, we import the fetchNotecardData()
function itself, import the getStaticProps
function from Next, and then at the end of the file, call fetchNotecardData()
from inside the getStaticProps
function.
Finally we return that data from Notehub as props
that can be passed to the Home
component.
Massage the data into shape for the <Map />
component
Good. We've got data from Notehub, and there's one last thing to do: take this JSON data returned from Notehub and re-shape it to fit the <Map />
component. Once again, I've condensed down the logic to make this file easier to read, but you can see the full file on GitHub.
// imports
import { useEffect, useState } from "react";
import dayjs from "dayjs";
// other imports
type dataProps = {
// condensed for code brevity
};
export default function Home({ data }: { data: dataProps[] }) {
// map component imported dynamically here
// state variables for the various pieces of data passed to the map
const [lngLatCoords, setLngLatCoords] = useState<number[][]>([]);
const [lastPosition, setLastPosition] = useState<[number, number]>([
00.00, 00.00
]);
const [latestTimestamp, setLatestTimestamp] = useState<string>("");
const [latLngMarkerPositions, setLatLngMarkerPositions] = useState<
[number, number][]
>([]);
// logic to transform data into the items needed to pass to the map
useEffect(() => {
const lngLatArray: number[][] = [];
const latLngArray: [number, number][] = [];
if (data && data.length > 0) {
data
.sort((a, b) => {
return Number(a.captured) - Number(b.captured);
})
.map((event) => {
let lngLatCoords: number[] = [];
let latLngCoords: [number, number] = [0, 1];
lngLatCoords = [
event.gps_location?.longitude,
event.gps_location?.latitude,
];
latLngCoords = [
event.gps_location?.latitude,
event.gps_location?.longitude,
];
lngLatArray.push(lngLatCoords);
latLngArray.push(latLngCoords);
});
const lastEvent = data.at(-1);
let lastCoords: [number, number] = [0, 1];
lastCoords = [
lastEvent.gps_location.latitude,
lastEvent.gps_location.longitude,
];
setLastPosition(lastCoords);
const timestamp = dayjs(lastEvent?.captured).format("MMM D, YYYY h:mm A");
setLatestTimestamp(timestamp);
}
setLngLatCoords(lngLatArray);
setLatLngMarkerPositions(latLngArray);
}, [data]);
return (
<div>
{/* extra tracker app code */}
<main>
<h1>React Blues Wireless Asset Tracker</h1>
{/* other tracker components */}
<div>
<MapWithNoSSR
coords={lngLatCoords}
lastPosition={lastPosition}
markers={latLngMarkerPositions}
latestTimestamp={latestTimestamp}
/>
</div>
{/* other tracker components */}
</main>
</div>
);
}
// getStaticProps call to Notehub
In this function, once the data is fetched from Notehub and passed to the component, we set some new React useState
variables to hold the data to pass to the <Map />
component.
-
There's a
lngLatCoords
list - this is list of coordinates that will be used to draw the lines between the recorded GPS coordinates (and it must be passed as[longitude, latitude]
to the"LineString"
<GeoJSON />
component). -
The
lastPosition
state variable is used to center the map, center the marker icon, and display in the<Popup />
along with thelatestTimestamp
variable. -
And the
latLngMarkerPositions
list is similar to thelngLatCoords
variable except the coordinates here are in the order of[latitude, longitude]
.
Inside of the useEffect()
function the array of Notehub events is sorted and then iterated over to pull out all the relevant data in the shape required. And once all the events have been transformed, they're set in state and passed to the map.
Update the data regularly using ISR
There's one last thing we have not yet discussed and that is how to handle new _track.qo
events that get sent to Notehub after the asset tracking app originally loads the data and renders the map.
Early on as I was building my tracker, I had a simple refreshData()
function I was using to force Next.js to refetch Notehub data on the server-side on an interval every 5 minutes, but then I learned a much better way that is actually built in to Next: incremental static regeneration (ISR).
ISR enables us to use static-generation on a per-page basis, without needing to rebuild the entire site. What this means in practice is:
getStaticProps
is still called to fetch the data, but additionally a revalidate
option is passed in to the return
statement with an interval time in seconds. Every time that interval is reached, Next.js will call the function again and attempt to regenerate the page with any new data, and once the page has been regenerated Next will replace the old page with the new one. No loading messages, no screen jank, no extra libraries like WebSockets or long-polling or extra functions to force server-side refreshes manually - instead it's built in to Next.
So to enable this revalidation of the page data on a regular interval, we'll turn back to the index.tsx
page once more.
// imports
import { GetStaticProps } from "next";
import { fetchNotecardData } from "../src/lib/notecardData";
// other imports
type dataProps = {
// condensed for code brevity
};
export default function Home({ data }: { data: dataProps[] }) {
// map component imported dynamically here
// logic to transform data into the items needed to pass to the map
return (
<div>
{/* extra tracker app code */}
<main>
<h1>React Blues Wireless Asset Tracker</h1>
{/* other tracker components */}
<div>
<MapWithNoSSR
coords={lngLatCoords}
lastPosition={lastPosition}
markers={latLngMarkerPositions}
latestTimestamp={latestTimestamp}
/>
</div>
{/* other tracker components */}
</main>
</div>
);
}
export const getStaticProps: GetStaticProps = async () => {
/* we're able to use Nextjs's ISR (incremental static regneration)
revalidate functionality to re-fetch updated map coords and re-render one a regular interval */
const data = await fetchNotecardData();
return { props: { data }, revalidate: 120 };
The new code is at the very bottom of the getStaticProps
function in this file - the line return { props: { data }, revalidate: 120 };
This is all that's needed so that every two minutes Next.js will go back to Notehub and fetch any new data and re-render the page server-side. It's awesome.
And with that, we've got an asset tracker map built with Next.js, and regularly checking for new data.
Conclusion
After I joined an IoT startup in July of 2021, I started dipping my toes into internet of things development with an asset tracker. And once I had GPS data being sent regularly to a cloud, I figured out how to extract that data from the cloud and display it in a custom-built dashboard map.
With the help of the React-powered Next.js framework and the React Leaflet library, I was able to do so easily, and even leverage Next's built-in incremental static regeneration to re-fetch any new data server-side and re-render the map with that fresh data. It's pretty cool.
This actually came in pretty handy when my parents' car was stolen from their driveway the night after Thanksgiving. If you want to hear the whole story and build your own tracker, check out this blog post and video I made for Blues Wireless - it details the whole process from hardware to software to deploying to Netlify.
Check back in a few weeks — I’ll be writing more about JavaScript, React, IoT, or something else related to web development.
Thanks for reading. I hope you enjoyed learning how to set up a map in Next.js and render an asset tracker's location data to that map - just think how useful this could be for keeping track of a personal vehicle or a whole fleet of them. There's a lot of cool places you could take this project from here. Happy tracking!
References & Further Resources
Want to be notified first when I publish new content? Subscribe to my newsletter.