DEV Community

Cover image for Create an Asset Tracker with Next.js and React Leaflet
Paige Niedringhaus
Paige Niedringhaus

Posted on • Edited on • Originally published at paigeniedringhaus.com

Create an Asset Tracker with Next.js and React Leaflet

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

Please 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 repo from GitHu.

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


Enter fullscreen mode Exit fullscreen mode

Note: You'll also need react and react-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 


Enter fullscreen mode Exit fullscreen mode

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]",
  },
};


Enter fullscreen mode Exit fullscreen mode

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

React-Leaflet map rendering the asset tracker's location

This is how the component looks rendered out with GPS data.

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.

Map.tsx



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)}&#176;,&nbsp;
            {lastPosition[1].toFixed(3)}&#176;
            <br />
            {latestTimestamp}
          </Popup>
          <GeoJSON data={geoJsonObj}></GeoJSON>
          {mapMarkers}
        </Marker>
      </MapContainer>
    </>
  );
};

export default Map;


Enter fullscreen mode Exit fullscreen mode

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 - an 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.

pages/index.tsx



// 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


Enter fullscreen mode Exit fullscreen mode

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:

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.

  1. 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 scheduled outbound 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)


Enter fullscreen mode Exit fullscreen mode

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":"[you@youremail.com]", "password": "[your_password]"}'


Enter fullscreen mode Exit fullscreen mode

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:

.env.local



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


Enter fullscreen mode Exit fullscreen mode

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 with NEXT_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.

notecardData.ts



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;
}


Enter fullscreen mode Exit fullscreen mode

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.

pages/index.tsx



// 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 tranform 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 } };
};


Enter fullscreen mode Exit fullscreen mode

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.

pages/index.tsx



// 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


Enter fullscreen mode Exit fullscreen mode

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 the latestTimestamp variable.

  • And the latLngMarkerPositions list is similar to the lngLatCoords 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.

pages/index.tsx



// 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 tranform 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 regeneration) 
  revalidate functionality to re-fetch updated map coords and re-render one a regular interval */
  const data = await fetchNotecardData();

  return { props: { data }, revalidate: 120 };


Enter fullscreen mode Exit fullscreen mode

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. If you’d like to make sure you never miss an article I write, sign up for my newsletter here: https://paigeniedringhaus.substack.com

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

Top comments (0)