Next.js 13, Mapbox, Deck.gl Hexagon Layer step by step.

PiotrDev
9 min readFeb 17, 2023

Next.js 13 comes with fundamental changes vs 12, making it one of the best for Full Stack development. The introduction of the new /app Directory (still beta ver) and improved router and layout experience help build clear and well-structured, scalable Web Apps.

In this project, I want to combine Next.js 13 features with Mapbox map provider leveraging react-map-gl, a suite of React components for Mapbox GL JS-compatible libraries. I will also use deck.gl allowing complex visualizations to be constructed by composing existing layers.

The outcome will be a Hexagon layer map of parcel lockers owned by InPost company (based on the free API they provide).

We begin with a simple create-next app to build Next.js regular Next folder tree. (I am not using TypeScript for this particular demo).

I will be using TailwindCSS for this project.

npx create-next-app yourprojectname
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Update tailwind.config.js with the below code snippet. As you can see, Tailwind is ready for the new app directory tree structure of Next.js 13, yet to be created in our project.

(source: https://tailwindcss.com/docs/guides/nextjs)

/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",

// Or if using `src` directory:
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

Add the following to your styles/global.css and remove Home.module.css.

@tailwind base;
@tailwind components;
@tailwind utilities;

Also, update next.config.js file with experimental mode on addDir:

/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
appDir: true,
},
}

module.exports = nextConfig

Subsequently, create app folder in the main directory of your project, and in the app folder create a page.js file (you can then delete the page.js file from the pages folder). Once done, you can run the dev mode with npm run dev. If all is correct, then Next.js is creating 2 files in your app directory, head.js and layout.js. A layout is UI that is shared between multiple pages. On navigation, layouts preserve state, remain interactive, and do not re-render. Layouts can also be nested. You can define a layout by default exporting a React component from a layout.js file. The component should accept a children prop that will be populated with a child layout (if it exists) or a child page during rendering. (src: https://beta.nextjs.org/docs/routing/pages-and-layouts)

Now, let’s import our Tailwind-ready styles and add some initial styling to the project to layout.js:

// app/layout.js
import "../styles/globals.css";

export default function RootLayout({ children }) {
return (
<html>
<head />

<body className="min-h-screen bg-slate-900">

<main className="">
{children}
</main>

</body>
</html>
);
}

We can start creating Map component in components folder outside app directory. (reminder: anything inside app directory is a server component unless “use client” directive is used). Our boilerplate:

// components/Map.jsx

import React from 'react'

const LocationAggregatorMap = () => {
return (
<div>Map</div>
)
}

export default LocationAggregatorMap

Adding two previously mentioned libraries deck.gl and react-map-gl.

deck.gl is a WebGL-powered framework for visual exploratory data analysis of large datasets with API designed to reflect the reactive programming paradigm. Whether using VanillaJS or the React interface, it can handle efficient WebGL rendering under heavy data load. Have a look at deck.gl documentation.

react-map-gl makes using Mapbox GL JS in React applications easy. I encourage you to review react-map-gl documentation here.

npm i deck.gl react-map-gl

For this project, we will be using Mapbox as the map provider. You will need to create a free account to get an API key to render the map on your app. Once the API key is created, paste it to your .env.local file:

// .env.local file in main directory

NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=yourapikeyfrommapbox

Now, we start building our Map component:

  1. Starting with importing essential libraries to the component and CSS file to service the mapbox-gl (“mapbox-gl/dist/mapbox-gl.css”)
"use client"
// components/Map.jsx

import React, { useState } from 'react'

import Map from 'react-map-gl'
import { HexagonLayer } from '@deck.gl/aggregation-layers'
import DeckGL from '@deck.gl/react'
import "mapbox-gl/dist/mapbox-gl.css"


const LocationAggregatorMap = () => {
return (
<div>Map</div>
)
}

export default LocationAggregatorMap

2. Creating configuration file for our map — lib/mapconfig.js:

This file sets key parameters for the styling of the hexagon bars we will use, colour intensity based on aggregation density, and the INITIAL_VIEW_STATE of the map (i.e. focus, zoom, bearing, pitch and initial geo-coordinates).

import { AmbientLight, PointLight, LightingEffect } from "@deck.gl/core";

export const ambientLight = new AmbientLight({
color: [255, 255, 255],
intensity: 1.0,
});

export const pointLight1 = new PointLight({
color: [255, 255, 255],
intensity: 0.8,
position: [-0.144528, 49.739968, 80000],
});

export const pointLight2 = new PointLight({
color: [255, 255, 255],
intensity: 0.8,
position: [-3.807751, 54.104682, 8000],
});

export const lightingEffect = new LightingEffect({
ambientLight,
pointLight1,
pointLight2,
});

export const material = {
ambient: 0.64,
diffuse: 0.6,
shininess: 32,
specularColor: [51, 51, 51],
};

export const INITIAL_VIEW_STATE = {
longitude: 19.134378,
latitude: 51.9189,
zoom: 6.8,
minZoom: 5,
maxZoom: 15,
pitch: 40.5,
bearing: -27
};

export const colorRange = [
[1, 152, 189],
[73, 227, 206],
[216, 254, 181],
[254, 237, 177],
[254, 173, 84],
[209, 55, 78],
];

3. Import this file to our Map component and set the initial parameters:

"use client";
// components/Map.jsx

import React, { useState } from "react";

import Map from "react-map-gl";
import { HexagonLayer } from "@deck.gl/aggregation-layers";
import DeckGL from "@deck.gl/react";
import "mapbox-gl/dist/mapbox-gl.css";

// import map config
import {
lightingEffect,
material,
INITIAL_VIEW_STATE,
colorRange,
} from "../lib/mapconfig.js";

const LocationAggregatorMap = () => {


return (
<div>
<DeckGL
effects={[lightingEffect]}
initialViewState={INITIAL_VIEW_STATE}
controller={true}
>
<Map
className=""
controller={true}
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}
mapStyle="mapbox://styles/petherem/cl2hdvc6r003114n2jgmmdr24"
></Map>
</DeckGL>
</div>
);
};

export default LocationAggregatorMap;

4. Now, we should import the above Map component (named here LocationAggregatorMap) to app/page.js and wrap it with a div set to ‘relative’.

import React from 'react'
import LocationAggregatorMap from '../components/Map'

const HomePage = () => {
return (
<div className="relative min-h-screen">
<LocationAggregatorMap />
</div>
)
}

export default HomePage

5. [MILESTONE] If there is no mistake, we run: npm run dev and should be able to see the map rendered. If the map is not rendered, please check your API key importing with process.env and make sure you have made the wrapping div ‘relative’.

6. Now we need to pull the data from the API, retrieve the geo-coordinates from the API response, set it as an array of arrays (geo coordinates) and then pass it Map component. As we are using ‘useState’ and ‘useEffect’ hooks, we need to specify that pages.js will be ‘client-side’ component with the ‘use client’ directive.

NOTE: we are using the official, free API of InPost company (European Self-Service Parcel lockers)

"use client";

import React, { useState, useEffect } from "react";
import LocationAggregatorMap from "../components/Map";

const HomePage = () => {
const [details, setDetails] = useState([]);
const [coordinates, setCoordinates] = useState([]);

useEffect(() => {
const getData = async () => {
const response = await fetch(
"https://api-shipx-pl.easypack24.net/v1/points?per_page=28000"
);

const data = await response.json();
setDetails(data.items);

// Create an array of geo coordinates pairs
const coords = data.items.map((item) => [
item.location.longitude,
item.location.latitude,
]);
setCoordinates(coords);
};
getData();
}, []);
console.log(coordinates);

return (
<div className="relative min-h-screen">
<LocationAggregatorMap />
</div>
);
};

export default HomePage;

If everything is good, we should be able to see in our browser console the array of arrays with geo coordinates pairs retrieve from the API response. If you want to see the initial API response, check in Postman or directly in the browser (JSON viewer might be useful). We removed the initial pagination by setting ?per_page=28000 parameter.

7. Now, we pass our set of arrays to Map component:

<LocationAggregatorMap data={coordinates} />

8. We update the Map component with initial props such as upperPercentile=100, coverage=1, and reception of our data from API.

9. We also create a map ‘layer’ — Hexagon passing the respective parameters (see deck.gl documentation on Hexagon layer) along with the data retrieved from API. At this moment, we set the static radius of the Hexagon to 1000m.

"use client";
// components/Map.jsx

import React, { useState } from "react";

import Map from "react-map-gl";
import { HexagonLayer } from "@deck.gl/aggregation-layers";
import DeckGL from "@deck.gl/react";
import "mapbox-gl/dist/mapbox-gl.css";

// import map config
import {
lightingEffect,
material,
INITIAL_VIEW_STATE,
colorRange,
} from "../lib/mapconfig.js";

const LocationAggregatorMap = ({
upperPercentile = 100,
coverage = 1,
data,

}) => {


const layers = [
new HexagonLayer({
id: "heatmap",
colorRange,
coverage,
data,
elevationRange: [0, 3000],
elevationScale: data && data.length ? 50 : 0,
extruded: true,
getPosition: (d) => d,
pickable: true,
radius: 1000,
upperPercentile,
material,

transitions: {
elevationScale: 3000,
},
}),
];

return (
<div>
<DeckGL
layers={layers}
effects={[lightingEffect]}
initialViewState={INITIAL_VIEW_STATE}
controller={true}
>
<Map
className=""
controller={true}
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}
mapStyle="mapbox://styles/petherem/cl2hdvc6r003114n2jgmmdr24"
></Map>
</DeckGL>
</div>
);
};

export default LocationAggregatorMap;

10. [MILESTONE] if everything goes well, we should see our Hexagon bars plotted on the map.

11. Let’s add Tooltip to our map to browse the aggregation of parcel lockers and see respective geo-coordinates for each hex.

"use client";
// components/Map.jsx

import React, { useState } from "react";

import Map from "react-map-gl";
import { HexagonLayer } from "@deck.gl/aggregation-layers";
import DeckGL from "@deck.gl/react";
import "mapbox-gl/dist/mapbox-gl.css";

// import map config
import {
lightingEffect,
material,
INITIAL_VIEW_STATE,
colorRange,
} from "../lib/mapconfig.js";

const LocationAggregatorMap = ({
upperPercentile = 100,
coverage = 1,
data,
}) => {

// creating tooltip
function getTooltip({ object }) {
if (!object) {
return null;
}
const lat = object.position[1];
const lng = object.position[0];
const count = object.points.length;

return `\
latitude: ${Number.isFinite(lat) ? lat.toFixed(6) : ""}
longitude: ${Number.isFinite(lng) ? lng.toFixed(6) : ""}
${count} locations here`;
}



const layers = [
new HexagonLayer({
id: "heatmap",
colorRange,
coverage,
data,
elevationRange: [0, 3000],
elevationScale: data && data.length ? 50 : 0,
extruded: true,
getPosition: (d) => d,
pickable: true,
radius: 1000,
upperPercentile,
material,

transitions: {
elevationScale: 3000,
},
}),
];

return (
<div>
<DeckGL
layers={layers}
effects={[lightingEffect]}
initialViewState={INITIAL_VIEW_STATE}
controller={true}
getTooltip={getTooltip}
>
<Map
className=""
controller={true}
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}
mapStyle="mapbox://styles/petherem/cl2hdvc6r003114n2jgmmdr24"
></Map>
</DeckGL>
</div>
);
};

export default LocationAggregatorMap;

12. Let’s also create the Map controller to adjust hex radius spontaneously.

"use client";
// components/Map.jsx

import React, { useState } from "react";

import Map from "react-map-gl";
import { HexagonLayer } from "@deck.gl/aggregation-layers";
import DeckGL from "@deck.gl/react";
import "mapbox-gl/dist/mapbox-gl.css";

// import map config
import {
lightingEffect,
material,
INITIAL_VIEW_STATE,
colorRange,
} from "../lib/mapconfig.js";

const LocationAggregatorMap = ({
upperPercentile = 100,
coverage = 1,
data,
}) => {
const [radius, setRadius] = useState(1000);

const handleRadiusChange = (e) => {
console.log(e.target.value);
setRadius(e.target.value);
};


// creating tooltip
function getTooltip({ object }) {
if (!object) {
return null;
}
const lat = object.position[1];
const lng = object.position[0];
const count = object.points.length;

return `\
latitude: ${Number.isFinite(lat) ? lat.toFixed(6) : ""}
longitude: ${Number.isFinite(lng) ? lng.toFixed(6) : ""}
${count} locations here`;
}

const layers = [
new HexagonLayer({
id: "heatmap",
colorRange,
coverage,
data,
elevationRange: [0, 3000],
elevationScale: data && data.length ? 50 : 0,
extruded: true,
getPosition: (d) => d,
pickable: true,
radius,
upperPercentile,
material,

transitions: {
elevationScale: 3000,
},
}),
];

return (
<div className="">
<DeckGL
layers={layers}
effects={[lightingEffect]}
initialViewState={INITIAL_VIEW_STATE}
controller={true}
getTooltip={getTooltip}
>
<Map
className=""
controller={true}
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}
mapStyle="mapbox://styles/petherem/cl2hdvc6r003114n2jgmmdr24"
></Map>

{/* FLOATING CONTROLLER */}

<div className="absolute bg-slate-900 text-white min-h-[200px] h-auto w-[250px] top-10 left-5 rounded-lg p-4 text-sm">
<div className="flex flex-col">
<h2 className="font-bold text-xl uppercase mb-1">Map controller</h2>
<h2 className="font-bold text-md uppercase mb-4">INPOST LOCS</h2>
<input
name="radius"
className="w-fit py-2"
type="range"
value={radius}
min={500}
step={50}
max={10000}
onChange={handleRadiusChange}
/>
<label htmlFor="radius">
Radius -{" "}
<span className="bg-indigo-500 font-bold text-white px-2 py-1 rounded-lg">
{radius}
</span>{" "}
meters
</label>
<p>
{" "}
<span className="font-bold">{data.length}</span> Locations
</p>
</div>
</div>

</DeckGL>
</div>
);
};

export default LocationAggregatorMap;

Great! We just created a Hexagon layer that aggregates data into a hexagon-based heatmap with Next.js 13, VanillaJS, fetch API and TailwindCSS.

--

--

PiotrDev

15 years in Payments | Banking | Finance (FT500). MBA Warwick Business School. Full Stack Developer (JS, React, Python) | www.piotrmaciejewski.com