← Back to list

How to Build Interactive Maps in Next.js with deck.gl and Geoapify

In this tutorial, I’ll show you how to use IconLayer and TextLayer with DeckGL in a Nextjs application. I’ll use data from Geoapify to…

Piotr Sobol · 2026-05-10 10:48 · 17 claps · 11.8 min read
#nextjs #deckgl #react #javascript #maps
Open on Medium ↗
Wiki topics: 🌐 · Web Development

How to Build Interactive Maps in Next.js with deck.gl and Geoapify

In this tutorial, I’ll show you how to use IconLayer and TextLayer with DeckGL in a Nextjs application. I’ll use data from Geoapify to build a map of restaurants in Miami, Florida. Here’s what we’ll cover:

  • Setting up a Next.js app with Deck.gl and React Map GL.
  • Fetching restaurant and fast food data from the Geoapify Places API.
  • Mapping raw GeoJSON features to customized objects type with category detection.
  • Rendering icons per category using IconLayer and a custom sprite atlas.
  • Adding labels with TextLayer and interactive tooltip.
  • Clustering markers at low zoom levels with Supercluster.

The project code is available in my repository and the app is hosted at https://miami-food-map.vercel.app.

Let’s get started.

Geoapify Places API

If you don’t have an account yet, go to the Geoapify website and create one. You’re going to need an API key to send requests. A free account enables you to use 3000 credits per day (you can see your usage in the Statistics panel of your project). Geoapify offers various APIs, but I will be using only the Places API. The best way to test and interact with data is the Playground, which offers smooth visualization of the fetched data.

In the docs, you can find everything you need to make a request. Geoapify provides data based on the OpenStreetMap system. You can specify the search area using a circle, rectangle, geometry, or place ID. In my case, I’m only interested in the area of Miami.

I would like to get list of the restaurants in Miami. When using Geoapify, it is worth checking the detailed list of all possible search categories. I was interested in the categories catering.fast_food and catering.restaurant (these can be broken down into subcategories, e.g., catering.restaurant.japanese or catering.fast_food.burger).

Before working with the spatial data, it is useful to examine the distribution of occurrences across categories.

This is how I created the final URL:

https://api.geoapify.com/v2/places?categories=commercial&filter=place:51607209473f7d52c059d36425f14d644440f00101f9012c25800000000000c002089203094d616e68617474616e&limit=20&apiKey=YOUR_API_KEY

Nextjs app setup

Create Nextjs app (I’m using Typescript and I’ll explain how to configure the types) and install map dependencies:

npx create-next-app@latest miami-food-map --yes 
npm install deck.gl react-map-gl maplibre-gl

In my application I will use hardcoded values ​​(search parameters and map position), so to store the constants I create a special index.ts file in the app/consts folder. MapViewState is a DeckGL type that describes what an object holding the map view state should look like.

// /app/consts/index.ts

// * DeckGL:
import { MapViewState } from '@deck.gl/core';

export const SEARCH_CATEGORIES = [
    'catering.fast_food',
    'catering.restaurant'
];
export const SEARCH_PLACE_ID = '5142c971913b0e54c0590cbdf1434ac83940f00101f9010191120000000000c002069203054d69616d69';
export const SEARCH_LIMIT = '500';

export const INITIAL_VIEW_STATE: MapViewState = {
    longitude: -80.19791458342058,
    latitude: 25.77230071927423,
    zoom: 13
};

Next, you need to create a route to retrieve data from Geoapify. To do this, you’ll need the API key from the Geoapify project, which you’ll need to paste into the .env file (important: don’t start the key name with NEXT_PUBLIC_, as it’s a secret and can’t be made public). I will also add a public variable to hold the Nextjs API address.

GEOAPIFY_API_KEY=<YOUR_API_KEY>
NEXT_PUBLIC_BASE_URL=http://localhost:3000

In the main folder I create the /api/geoapify and place the route.ts file there. I use the constants I created earlier to create the search url. I’m not focusing on API error handling in this project, but you can add them to the function.

// /app/api/geoapify/route.ts

// * Consts:
import { 
    SEARCH_CATEGORIES, 
    SEARCH_PLACE_ID,
    SEARCH_LIMIT
} from '@/app/consts';

export async function GET() {
    const url = new URL('https://api.geoapify.com/v2/places');

    url.searchParams.set('categories', SEARCH_CATEGORIES.join(','));
    url.searchParams.set('filter', `place:${SEARCH_PLACE_ID}`);
    url.searchParams.set('limit', SEARCH_LIMIT);
    url.searchParams.set('apiKey', process.env.GEOAPIFY_API_KEY ?? '');

    const data = await fetch(url);
    const json = await data.json();

    return new Response(JSON.stringify(json));
}

Run the application and check if there is data at localhost:3000/api/geoapify.

You can also fetch data directly in page.tsx. I wrote more about this in the issue.

The data should be in the correct format, so I will create the appropriate type that will represent the data from Geoapify. For this purpose I will create a folder for types in app/types/index.ts :

// /app/types/index.ts

export type GeoapifyFeature = {
    type: 'Feature';
    properties: {
        place_id: string;
        categories?: string[]; // I will assign icons to categories
        [key: string]: unknown;
    };
    geometry: {
        type: 'Point';
        coordinates: [number, number];
    };
};

Initial project structure (The rest of the files are default for Nextjs).

Initial project structure (The rest of the files are default for Nextjs).

DeckGL Map

The next step is to create a map. I’ll create it in the folder /app/map/Map.tsx(in the same folder I will also create a map.css file to style the map). Map should be a client component. As a map style I’m using alidade_smooth_dark (source: https://stadiamaps.com/). You can choose a different style from the Stadia Maps tiles. The map will accept geojson data (features) as an argument. For now, I’ll just pass it in, and add the layer later.

Since Stadia Maps can be used without an API key on localhost, but not in the deployment version, the app demo uses a different style with a similar appearance.

// /app/map/Map.tsx

"use client";

// * DeckGL:
import { DeckGL } from '@deck.gl/react';

// * React Map GL:
import { Map as ReactMapGl } from 'react-map-gl/maplibre';

// * Types:
import { GeoapifyFeature } from '@/app/types';

// * Consts:
import { INITIAL_VIEW_STATE } from '@/app/consts';

// * Styles:
import './map.css';

const Map = ({ features }: { features: GeoapifyFeature[] }) => {
    console.log('Features: ', features);

    return (
        <DeckGL 
            initialViewState={INITIAL_VIEW_STATE} 
            controller={true}
        >
            <ReactMapGl
                mapStyle="https://tiles.stadiamaps.com/styles/alidade_smooth_dark.json"
            />
        </DeckGL>
    )
}

export default Map;

Next, I’ll add a style to the map that will prevent additional overlays from rendering inside the map container.

/* /app/map/map.css */

.maplibregl-control-container {
    display: none;
}

[mapboxgl-children] {
    display: none;
}

In the main page.tsx file I will import the map and download data from the previously created route.

// /app/page.tsx

"use server";

// * React:
import { Suspense } from 'react';

// * UI:
import Map from '@/app/map/Map';

const HomePage = async () => {

    const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/geoapify`);
    const data = await res.json();

    return (
        <Suspense fallback={<div>Loading...</div>}>
            <Map features={data.features} />
        </Suspense>
    )
}

export default HomePage;

Features loaded into the Map component.

Features loaded into the Map component.

Mapping categories

Before adding the IconLayer, I’ll create my own data type to represent food places and call it Location. The GeojsonFeature type represents OSM data, and I need my own structure to easily categorize places (pizza, burgers, seafood, etc.).

// app/types/index.ts
// ...

export type Location = {
    id: string;
    name?: string;
    type: 'burger' | 'pizza' | 'sandwich' | 'seafood' | 'asian' | 'latin' | 'food' | 'pin';
    position: [number, number];
    properties: LocationProps;
}

export type LocationProps = {
    [key: string]: unknown;
}

I’ve defined six food types: pizza, burger, seafood, latin, sandwich, and asian. The rest will be assigned to the “food” placeholder category. Since the data from Geoapify is not directly assigned to these categories, I need to map the records to them. For this purpose, I will add an object with keys that will allow me to assign the Geoapify record to the appropriate food category.

// app/consts/index.ts
// ...

export const FOOD_CATEGORIES: Record<string, string> = {

    // Burger
    "catering.fast_food.burger": "burger",
    "catering.restaurant.burger": "burger",

    // Pizza
    "catering.fast_food.pizza": "pizza",
    "catering.restaurant.pizza": "pizza",
    "catering.restaurant.italian": "pizza",

    // ...
};

Once I know the keys that allow me to assign specific records to my own categories, I can create a function that will do it. To do this, I created getLocationType.ts in the /app/lib .

You can use a function in the Map component to see if the data is parsed into Location format.

// /app/lib/getLocationType.ts

// * Consts:
import { FOOD_CATEGORIES } from '@/app/consts';

// * Types:
import { Location } from '@/app/types';

export const getLocationType = (
    categories: string[] = []
): Location['type'] => {

    for (const category of categories) {
        const mappedType = FOOD_CATEGORIES[category];

        if (mappedType) {
            return mappedType as Location['type'];
        }
    }

    return "food";
};
// /app/map/Map.tsx
// ...

const Map = ({ features }: { features: GeoapifyFeature[] }) => {

    // Temp display:
    const mappedFeatures = features.map(feature => (
        {
            id: feature.properties.place_id,
            type: getLocationType(feature.properties.categories),
            position: feature.geometry.coordinates,
            name: feature.properties.name,
            properties: feature.properties
        }
    ));

    console.log(mappedFeatures);

// ...

Correctly parsed data in the Map component.

Correctly parsed data in the Map component.

IconLayer

Once the data is in the appropriate format, you can add an IconLayer. This requires a set of icons contained within a single image, called a sprite. A sprite is a regular image, for example, in .png format, with icons in specific positions. TexturePacker is useful for creating a sprite. Files with individual icons should be placed in the program and exported to a sprite sheet.

TexturePacker view with a sheet ready for export (menu in Polish).

TexturePacker view with a sheet ready for export (menu in Polish).

For IconLayer to know which icon is in a given position, it’s necessary to create a JSON file with a description of the position. I placed the file in the /public folder as sprite_food_mapping.json. It’s important to set mask: false if you’re using multicolor icons, as the color might be overridden by the getColor function. I also include a .png file with the sprite sprite_food.png in the public folder.

// /public/sprite_food_mapping.json

{
  "sandwich": {
    "x": 0,
    "y": 0,
    "width": 100,
    "height": 100,
    "mask": false
  },

  "latin": {
    "x": 100,
    "y": 0,
    "width": 100,
    "height": 100,
    "mask": false
  },

  "seafood": {
    "x": 200,
    "y": 0,
    "width": 100,
    "height": 100,
    "mask": false
  },

  // ...
}

Now you can add IconLayer to Map.

// /app/map/Map.tsx

// ..

// * DeckGL:
import { DeckGL } from '@deck.gl/react';
import { IconLayer } from '@deck.gl/layers';

// ...

// * Types:
import { GeoapifyFeature, Location } from '@/app/types';

// ...

const Map = ({ features }: { features: GeoapifyFeature[] }) => {

    // ...

    const iconLayer = new IconLayer<Location>({
        id: 'icon-layer',
        data: mappedFeatures,
        getPosition: d => d.position,
        iconAtlas: '/sprite_food.png',
        iconMapping: '/sprite_food_mapping.json',
        getIcon: d => d.type,
        pickable: true,
        getSize: 40
    });

    return (
        <DeckGL 
            initialViewState={INITIAL_VIEW_STATE} 
            controller={true}
            layers={[iconLayer]}
        >
            <ReactMapGl
                mapStyle="https://tiles.stadiamaps.com/styles/alidade_smooth_dark.json"
            />
        </DeckGL>
    )
}

export default Map;

Icons displayed on the map.

Icons displayed on the map.

Supercluser

As you can see, locations sometimes appear close to each. Since we want to avoid the accumulation of too many points in one place, such a situation should be handled using clustering. For this purpose, Mapbox has created a great library called Supercluster. It creates clusters, which represent a group of points. Since I only have 500 records, I will perform clustering on the client side, using a hook that I will create myself using supercluster. Let’s install the library:

npm install supercluster

I will create my hook in /hooks/useSupercluster.ts . To use the supercluster, you need to give it a features array, a zoom level, and the current viewport (map boundaries). First, we create a Supercluster object and give it two parameters, i.e. radius (from each point a circle with the given radius is drawn, if another point is inside the circle, a cluster is created) and maxZoom (above this zoom is not grouped). getClusters function retrieves the clusters for the current view.

// * Types:
import { GeoapifyFeature } from '@/app/types';

// * Supercluster:
import Supercluster from 'supercluster';

export const useSupercluster = (
    features: GeoapifyFeature[],
    zoom: number,
    bounds: [number, number, number, number] | null
) => {

    const supercluster = new Supercluster({
        radius: 60,
        maxZoom: 16
    }).load(features);

    const clusters = bounds ? supercluster.getClusters(bounds, Math.floor(zoom)) : [];

    console.log('clusters', clusters);

    return { clusters, supercluster };
}

To implement clustering, the Map component must be configured to support changing the map’s viewport. The map state in DeckGL is described by the ViewState object. Because it doesn’t contain direct information about the bounds, they must be recalculated. To do this, you can use useEffect and WebMercatorViewport, a tool that helps convert the map’s geographic coordinates to screen coordinates.

// /app/map/Map.tsx

// ...
import { WebMercatorViewport } from '@deck.gl/core';

// ..

// * Hooks:
import { useSupercluster } from '@/app/hooks/useSupercluster';

// ...

const Map = ({ features }: { features: GeoapifyFeature[] }) => {

    const [viewState, setViewState] = useState(INITIAL_VIEW_STATE);
    const [bounds, setBounds] = useState<[number, number, number, number] | null>(null);

    useEffect(() => {
        const calculateBounds = () => {
            const { longitude, latitude, zoom } = viewState;
            const viewport = new WebMercatorViewport({
                longitude,
                latitude,
                zoom,
                width: window.innerWidth,
                height: window.innerHeight
            });
            const newBounds = viewport.getBounds();
            setBounds(newBounds);
        };

        calculateBounds();
    }, [viewState]);

    const { clusters } = useSupercluster(features, viewState.zoom, bounds);
    console.log('clusters', clusters);

    // ..

    return (
        <DeckGL 
            initialViewState={viewState} 
            controller={true}
            layers={[iconLayer]}
            onViewStateChange={({ viewState }) => setViewState(viewState as typeof INITIAL_VIEW_STATE)}
        >
            <ReactMapGl
                mapStyle={process.env.NEXT_PUBLIC_MAP_STYLE}
            />
        </DeckGL>
    )
}

export default Map;

Using the scroll bar on the map you can see the clustering.

Using the scroll bar on the map you can see the clustering.

At this point, you can’t use clusters directly in IconLayer because IconLayer doesn’t know what icons a cluster should have. To do this, I’ll move the mapping on this Location into a separate function, also taking clusters into account. It is also worth adding a type representing the cluster.

// /app/types/index.ts

// ...

export type Cluster = {
    cluster?: boolean;
    cluster_id?: number;
    point_count?: number;
    name?: string;
    place_id?: string;
    categories?: string[];
    [key: string]: unknown;
};
// /app/lib/mapToLocation.ts

// * Types:
import { Cluster, Location } from "@/app/types";

// * Lib:
import { getLocationType } from "./getLocationType";

type ClusterFeature = GeoJSON.Feature<
    GeoJSON.Point,
    Cluster
>;

const mapClusterToLocation = (
    clusters: ClusterFeature[]
): Location[] => {
    return clusters.map((feature) => {
        const properties = feature.properties;
        const isCluster = Boolean(properties.cluster);

        return {
            id: isCluster
                ? `cluster-${properties.cluster_id}`
                : String(properties.place_id),
            name: properties?.name,
            position:
                feature.geometry.coordinates as [number, number],            
            type: isCluster ? "pin" : getLocationType(properties.categories),
            properties
        };
    });
};

export default mapClusterToLocation;

If an element is a cluster, a special icon is assigned to it. Now you can use this function in useSuperCluser, in such a way that it will return Location type.

// /app/hooks/useSupercluster.ts

// ...

// * Lib:
import mapClusterToLocation from '../lib/mapToLocation';

export const useSupercluster = (
    // ...
) => {

    // ...

    const clusters = bounds ? supercluster.getClusters(bounds, Math.floor(zoom)) : [];
    const locations = mapClusterToLocation(clusters);

    console.log('Locations:', locations);

    return { locations, supercluster };
}

In the Map component you just need to remove the old features mapping and replace it with locations from useSupercluser.

// /app/map/Map.tsx

// ...

const { locations } = useSupercluster(features, viewState.zoom, bounds);

const iconLayer = new IconLayer<Location>({
    id: 'icon-layer',
    data: locations,
    // ...
 });

// ...

Map component with clusters mapped to Location type.

Map component with clusters mapped to Location type.

TextLayer and Tooltip

Since each Location has some properties, it would be nice to display them on screen. To do this, you can add a TextLayer to the DeckGL instance.

A TextLayer can accept a Location type as data; we only need to specify where in the object the position information is located. I’d like to use the TextLayer so that when it displays a cluster, it only displays the number of items in the cluster, and if it’s a regular Location, it displays the name of the restaurant.

// /app/map/Map.tsx

// ...

import { IconLayer, TextLayer } from '@deck.gl/layers';

// ...

const textLayer = new TextLayer<Location>({
    id: 'text-layer',
    data: locations,
    getPosition: d => d.position,
    getText: d => d?.name || (d.properties?.point_count ? `${d.properties.point_count}` : ''),
    getSize: 12,
    getColor: d => d.type === 'pin' ? [255, 255, 255] : [166, 248, 255],
    getTextAnchor: 'middle',
    getAlignmentBaseline: d => d.type === 'pin' ? 'bottom' : 'top',
    getPixelOffset: d => d.type === 'pin' ? [-1, 0] : [0, 20],
    background: true,
    getBackgroundColor: d => d.type === 'pin' ? [0, 0, 0, 0] : [0, 0, 0, 100],
    backgroundPadding: [6, 4],
    maxWidth: 10,
    wordBreak: 'break-word'
});

return (
    <DeckGL 
         layers={[iconLayer, textLayer]}
         // ...
     >

// ...

I made some customizations to my TextLayer:

  • I specified a different color for the text in the cluster and regular locations.
  • I adjusted the location where the text should be displayed ( getAlignmentBaseline: vertical text anchor, getPixelOffset: offset text by a specific number of pixels in x and y coordinates, getTextAnchor: positions text horizontally relative to the center point).
  • I specified the maximum text width and allowed long words to be broken.
  • I added a background to the text with a specific color. To use the background, set background: true . Since the background must be applied to all text, I specified it as transparent for clusters.

Map with TextLayer and IconLayer applied.

Map with TextLayer and IconLayer applied.

The final step in the project is adding a tooltip. DeckGL offers its own PickingInfo mechanism. This is an object containing information about the item the user is currently pointing at or clicking on. In this case, if the user clicks on a point, PickingInfo will return a Location object. The DeckGL tooltip doesn’t render React components, so it must be rendered as HTML.

// /app/map/Map.tsx

// ...

import type { PickingInfo } from '@deck.gl/core';

// ...

<DeckGL 
    // ...
    getTooltip={({ object }: PickingInfo<Location>) =>
        object
            ? {
                html: `
                    <div style="display:flex;>
                        <p>${object.name || 'Unknown'}</p>
                        <!-- Tooltip's content -->    
                    </div>
                `,
                style: {
                    backgroundColor: 'rgba(0, 0, 0, 0.6)',
                    borderRadius: '4px',
                }
             }
          : null
        }
    >    
>
// ...

The app is ready! If you’ve made it this far, I have to congratulate you on your patience ;)

Final project’s structure.

Final project’s structure.

Resources


메타데이터
post_id
1f736b897e52
slug
custom-icon-maps-in-next-js-deckgl-iconlayer-geoapify-places-api-step-by-step-1f736b897e52
url
https://medium.com/@piotr.sobol/custom-icon-maps-in-next-js-deckgl-iconlayer-geoapify-places-api-step-by-step-1f736b897e52
canonical_url
https://medium.com/@piotr.sobol/custom-icon-maps-in-next-js-deckgl-iconlayer-geoapify-places-api-step-by-step-1f736b897e52
author_url
https://medium.com/@piotr.sobol
status
ok
fetched_at
2026-07-10 18:03:05