Build Interactive Maps in Next.js using Google Maps API
Tags: nextjs, maps, webdev, react
Published: 2023-01-13 11:33:50

This tutorial will demonstrate how to use google Maps API and Google Places API to build interactive maps in Next.js.

### Demo

Here is a demo of what we will be building by the end of this tutorial.

{% embed https://www.youtube.com/embed/JISDaSc7efU %}

### Project Setup

Let’s scaffold a new Next.js app using `create-next-app`.

`npx create-next-app@latest --ts`

We will be using a package called `@react-google-maps/api`, which provides simple bindings to [Google Maps Javascript API V3](https://developers.google.com/maps/documentation/javascript/). This package lets us use google maps specific react components and hooks in our app.

`npm install @react-google-maps/api` 

Similarly, in order to interact with Google Maps Places API, we’ll be using another package called `use-places-autocomplete`.

`npm install use-places-autocomplete`

### Create Google Cloud API Key

Let's create a new key for our app from the Google Cloud Console Credentials.

<img src="https://99darshan.com/images/posts/interactive-maps/gmaps-credentials.png" alt="Google-Cloud-Console-Credentials" width="250" />

Since we will be creating a client facing application, the api key would be exposed in the browser. So it’s a good idea to add some restrictions to the API Key. For example, in the screenshot below, we are specifying that only the requests originating from `yourdomain.com` are considered valid requests.

Also, our application only require the `Maps Javascript API` , `Places API` and `Geocoding API`, we’ve enabled only these APIs.


<img src="https://99darshan.com/images/posts/interactive-maps/restrict-gmaps-key.png" alt="restrict-google-maps-key" width="500" />

> Before we could associate any of the google APIs to the key, we may have to first enable those API from `Enabled APIs & Services` tab

<img src="https://99darshan.com/images/posts/interactive-maps/enable-gmaps-api-menu.png" alt="enable-gmaps-api-key-menu" width="250" />

<img src="https://99darshan.com/images/posts/interactive-maps/enable-api-screen.png" alt="enable-gmaps-api-key-screen"/>

### Rendering Map

In order to render the Map, we’d have to load the google Maps Script into our application.

Let’s use the `useLoadScript` hook provided by `@react-google-maps/api` package to lazy load the Google Maps Script.

`useLoadScript` expects a API Key, let’s use the key we generated from the Google Cloud Console in the previous step and access it using the `next.js` environment variable.

In addition to the API Key, `useLoadScript` hook accepts an optional `libraries` parameter where we could specify the array of additional google maps libraries such as `drawing`, `geometry`, `places`, etc.

The `useLoadScript` hook returns a `isLoaded` property which can be used to show a Loading component until the script is loaded successfully.

```ts
// pages/index.tsx

import { useLoadScript } from '@react-google-maps/api';
import type { NextPage } from 'next';
import styles from '../styles/Home.module.css';

const Home: NextPage = () => {
  const libraries = useMemo(() => ['places'], []);

  const { isLoaded } = useLoadScript({
    googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY as string,
    libraries: libraries as any,
  });

  if (!isLoaded) {
    return <p>Loading...</p>;
  }

  return <div className={styles.container}>Map Script Loaded...</div>;
};

export default Home;
```

Once the script is loaded, we can use `GoogleMap` component to load the actual map. `GoogleMap` component requires a `center` prop which defines the latitude and longitude of the center of the map.

We can define other properties such as `zoom`, `mapContainerStyle`, etc. Additionally, `GoogleMap` also accepts `MapOptions`, `mapTypeId` props and other event listeners for map load and click events e.g. `onLoad`, `onClick`, `onDrag` , etc.

```ts
// pages/index.tsx

import { useLoadScript, GoogleMap } from '@react-google-maps/api';
import type { NextPage } from 'next';
import { useMemo } from 'react';
import styles from '../styles/Home.module.css';

const Home: NextPage = () => {
  const libraries = useMemo(() => ['places'], []);
  const mapCenter = useMemo(
    () => ({ lat: 27.672932021393862, lng: 85.31184012689732 }),
    []
  );

  const mapOptions = useMemo<google.maps.MapOptions>(
    () => ({
      disableDefaultUI: true,
      clickableIcons: true,
      scrollwheel: false,
    }),
    []
  );

  const { isLoaded } = useLoadScript({
    googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY as string,
    libraries: libraries as any,
  });

  if (!isLoaded) {
    return <p>Loading...</p>;
  }

  return (
    <div className={styles.homeWrapper}>
      <div className={styles.sidebar}>
        <p>This is Sidebar...</p>
      </div>
      <GoogleMap
        options={mapOptions}
        zoom={14}
        center={mapCenter}
        mapTypeId={google.maps.MapTypeId.ROADMAP}
        mapContainerStyle={{ width: '800px', height: '800px' }}
        onLoad={() => console.log('Map Component Loaded...')}
      />
    </div>
  );
};

export default Home;
```

<img src="https://99darshan.com/images/posts/interactive-maps/map-render.png" alt="render-map" width="800"/>

### Drawing Over the Map

We can draw Markers, Rectangle, Circle, Polygon, etc. over the rendered map by passing the corresponding components as a child to `GoogleMap` component.

#### Drawing a Marker

`MarkerF` component provided by the `@react-google-maps/api` package can be passed as a children of `GoogleMap` component to draw a Marker over the map. `MarkerF` component requires a `position` prop to specify the latitude and longitude of where to place the Marker.

```tsx
<GoogleMap
  options={mapOptions}
  zoom={14}
  center={mapCenter}
  mapTypeId={google.maps.MapTypeId.ROADMAP}
  mapContainerStyle={{ width: '800px', height: '800px' }}
  onLoad={(map) => console.log('Map Loaded')}
>
  <MarkerF position={mapCenter} onLoad={() => console.log('Marker Loaded')} />
</GoogleMap>
```

<img src="https://99darshan.com/images/posts/interactive-maps/marker.png" alt="draw-marker-over-map" />

The icon of the Marker can be changed by specifying an image URL in the `icon` prop.

```tsx
<GoogleMap
  options={mapOptions}
  zoom={14}
  center={mapCenter}
  mapTypeId={google.maps.MapTypeId.ROADMAP}
  mapContainerStyle={{ width: '800px', height: '800px' }}
  onLoad={(map) => console.log('Map Loaded')}
>
  <MarkerF
    position={mapCenter}
    onLoad={() => console.log('Marker Loaded')}
    icon="https://picsum.photos/64"
  />
</GoogleMap>
```

<img src="https://99darshan.com/images/posts/interactive-maps/marker-icon.png" alt="draw-marker-with-custom-icon"/>

#### Drawing Circles

We can draw circles over the map using `CircleF` component provided by the `@react-google-maps/api` package.

Let’s draw two concentric circles with different radius over the map. We can define properties of the circle such as fill color, fill opacity, stroke color, stroke opacity, etc. using the `options` prop.

Here, we’ve marked the inner circle as green and the outer circle as red.

```tsx
<GoogleMap
  options={mapOptions}
  zoom={14}
  center={mapCenter}
  mapTypeId={google.maps.MapTypeId.ROADMAP}
  mapContainerStyle={{ width: '800px', height: '800px' }}
  onLoad={(map) => console.log('Map Loaded')}
>
  <MarkerF position={mapCenter} onLoad={() => console.log('Marker Loaded')} />

  {[1000, 2500].map((radius, idx) => {
    return (
      <CircleF
        key={idx}
        center={mapCenter}
        radius={radius}
        onLoad={() => console.log('Circle Load...')}
        options={{
          fillColor: radius > 1000 ? 'red' : 'green',
          strokeColor: radius > 1000 ? 'red' : 'green',
          strokeOpacity: 0.8,
        }}
      />
    );
  })}
</GoogleMap>
```

<img src="https://99darshan.com/images/posts/interactive-maps/drawing-circles.png" alt="draw-circles-over-map" />

### Places Autocomplete

Google Places API can be used to create a autocomplete typeahead dropdown input with suggested addresses.

We will be using `use-places-autocomplete` library to help build the UI component for the autocomplete input. The library is small, has built in caching and debounce mechanism to reduce API calls to Google APIs.

`usePlacesAutcomplete` hook can be configured with `requestOptions` which is the request options of [Google Maps Places API](https://developers.google.com/maps/documentation/javascript/reference/places-autocomplete-service#AutocompletionRequest)

We can specify the number of milliseconds to delay before making a request to Google Maps Places API by using the `debounce` property. Similarly, `cache` property would specify the number of seconds to cache the response data from the Google Maps Places API.

The `usePlacesAutcomplete` hook returns a suggestions object with status and data from the Google Places API. It also provides helper methods to `setValue` of the input field, `clear` autocomplete suggestions, `clearCache`, etc.

```ts
const {
  ready,
  value,
  suggestions: { status, data }, // results from Google Places API for the given search term
  setValue, // use this method to link input value with the autocomplete hook
  clearSuggestions,
} = usePlacesAutocomplete({
  requestOptions: { componentRestrictions: { country: 'us' } }, // restrict search to US
  debounce: 300,
  cache: 86400,
});
```

We create a `input` field and attach a `onChange` handler to it which passes the user input value to the autocomplete hook using the `setValue()` helper method. If we get the OK status form the google places API, we render the suggested results using ul element.

```tsx
return (
  <div className={styles.autocompleteWrapper}>
    <input
      value={value}
      className={styles.autocompleteInput}
      disabled={!ready}
      onChange={(e) => setValue(e.target.value)}
      placeholder="123 Stariway To Heaven"
    />

    {status === 'OK' && (
      <ul className={styles.suggestionWrapper}>{renderSuggestions()}</ul>
    )}
  </div>
);
```

The `renderSuggestions()` is a helper method which uses the `data` returned from the `usePlacesAutcomplete` hook and render the list of `li` elements.

```ts
const renderSuggestions = () => {
  return data.map((suggestion) => {
    const {
      place_id,
      structured_formatting: { main_text, secondary_text },
      description,
    } = suggestion;

    return (
      <li
        key={place_id}
        onClick={() => {
          setValue(description, false);
          clearSuggestions();
          onAddressSelect && onAddressSelect(description);
        }}
      >
        <strong>{main_text}</strong> <small>{secondary_text}</small>
      </li>
    );
  });
};
```

In nutshell, when user starts entering an address in the input field, `usePlacesAutocomplete` hook would make an API call to Google Places API and return a list of suggestions. We would render those suggestions as selectable list of options.

**The `PlacesAutocomplete` component would look like following:**

```tsx
const PlacesAutocomplete = ({
  onAddressSelect,
}: {
  onAddressSelect?: (address: string) => void;
}) => {
  const {
    ready,
    value,
    suggestions: { status, data },
    setValue,
    clearSuggestions,
  } = usePlacesAutocomplete({
    requestOptions: { componentRestrictions: { country: 'us' } },
    debounce: 300,
    cache: 86400,
  });

  const renderSuggestions = () => {
    return data.map((suggestion) => {
      const {
        place_id,
        structured_formatting: { main_text, secondary_text },
        description,
      } = suggestion;

      return (
        <li
          key={place_id}
          onClick={() => {
            setValue(description, false);
            clearSuggestions();
            onAddressSelect && onAddressSelect(description);
          }}
        >
          <strong>{main_text}</strong> <small>{secondary_text}</small>
        </li>
      );
    });
  };

  return (
    <div className={styles.autocompleteWrapper}>
      <input
        value={value}
        className={styles.autocompleteInput}
        disabled={!ready}
        onChange={(e) => setValue(e.target.value)}
        placeholder="123 Stariway To Heaven"
      />

      {status === 'OK' && (
        <ul className={styles.suggestionWrapper}>{renderSuggestions()}</ul>
      )}
    </div>
  );
};
```

When user selects an address, we want to re-render the map and center it to the user’s selected address. In order to do that, we need to maintain states for `lat` and `lng` and update the state when user selects one of the auto suggested option.

Pass a custom event handler which computes the `lat`, `lng` from the selected address and updates the local state. `getGeoCode` and `getLatLng` are utility functions provided by `use-places-autocomplete` hook package.

**Adding the `PlacesAutocomplete` component to the `Home` component.**

```tsx
const Home: NextPage = () => {
	  // Store lat, lng as State Variables
  const [lat, setLat] = useState(27.672932021393862);
  const [lng, setLng] = useState(85.31184012689732);
 // Add lat, lng as dependencies
  const mapCenter = useMemo(() => ({ lat: lat, lng: lng }), [lat, lng]);

	...

	return (
    <div className={styles.homeWrapper}>
      <div className={styles.sidebar}>
		{/* render Places Auto Complete and pass custom handler which updates the state */}
        <PlacesAutocomplete
          onAddressSelect={(address) => {
            getGeocode({ address: address }).then((results) => {
              const { lat, lng } = getLatLng(results[0]);

              setLat(lat);
              setLng(lng);
            });
          }}
        />
     </div>

		 ...
		</div>
	)
}
```

<img src="https://99darshan.com/images/posts/interactive-maps/places-autocomplete.png" alt="places-autocomplete" />

### Conclusion

In this guide, we learned how we can leverage couple of lightweight packages to interact with the google maps API and the google places API to build interactive maps in `Next.js`

### Final Code

`index.tsx` with `Home` and `PlacesAutoComplete` Component.

```tsx
import {
  useLoadScript,
  GoogleMap,
  MarkerF,
  CircleF,
} from '@react-google-maps/api';
import type { NextPage } from 'next';
import { useMemo, useState } from 'react';
import usePlacesAutocomplete, {
  getGeocode,
  getLatLng,
} from 'use-places-autocomplete';
import styles from '../styles/Home.module.css';

const Home: NextPage = () => {
  const [lat, setLat] = useState(27.672932021393862);
  const [lng, setLng] = useState(85.31184012689732);

  const libraries = useMemo(() => ['places'], []);
  const mapCenter = useMemo(() => ({ lat: lat, lng: lng }), [lat, lng]);

  const mapOptions = useMemo<google.maps.MapOptions>(
    () => ({
      disableDefaultUI: true,
      clickableIcons: true,
      scrollwheel: false,
    }),
    []
  );

  const { isLoaded } = useLoadScript({
    googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY as string,
    libraries: libraries as any,
  });

  if (!isLoaded) {
    return <p>Loading...</p>;
  }

  return (
    <div className={styles.homeWrapper}>
      <div className={styles.sidebar}>
        {/* render Places Auto Complete and pass custom handler which updates the state */}
        <PlacesAutocomplete
          onAddressSelect={(address) => {
            getGeocode({ address: address }).then((results) => {
              const { lat, lng } = getLatLng(results[0]);

              setLat(lat);
              setLng(lng);
            });
          }}
        />
      </div>
      <GoogleMap
        options={mapOptions}
        zoom={14}
        center={mapCenter}
        mapTypeId={google.maps.MapTypeId.ROADMAP}
        mapContainerStyle={{ width: '800px', height: '800px' }}
        onLoad={(map) => console.log('Map Loaded')}
      >
        <MarkerF
          position={mapCenter}
          onLoad={() => console.log('Marker Loaded')}
        />

        {[1000, 2500].map((radius, idx) => {
          return (
            <CircleF
              key={idx}
              center={mapCenter}
              radius={radius}
              onLoad={() => console.log('Circle Load...')}
              options={{
                fillColor: radius > 1000 ? 'red' : 'green',
                strokeColor: radius > 1000 ? 'red' : 'green',
                strokeOpacity: 0.8,
              }}
            />
          );
        })}
      </GoogleMap>
    </div>
  );
};

const PlacesAutocomplete = ({
  onAddressSelect,
}: {
  onAddressSelect?: (address: string) => void;
}) => {
  const {
    ready,
    value,
    suggestions: { status, data },
    setValue,
    clearSuggestions,
  } = usePlacesAutocomplete({
    requestOptions: { componentRestrictions: { country: 'us' } },
    debounce: 300,
    cache: 86400,
  });

  const renderSuggestions = () => {
    return data.map((suggestion) => {
      const {
        place_id,
        structured_formatting: { main_text, secondary_text },
        description,
      } = suggestion;

      return (
        <li
          key={place_id}
          onClick={() => {
            setValue(description, false);
            clearSuggestions();
            onAddressSelect && onAddressSelect(description);
          }}
        >
          <strong>{main_text}</strong> <small>{secondary_text}</small>
        </li>
      );
    });
  };

  return (
    <div className={styles.autocompleteWrapper}>
      <input
        value={value}
        className={styles.autocompleteInput}
        disabled={!ready}
        onChange={(e) => setValue(e.target.value)}
        placeholder="123 Stariway To Heaven"
      />

      {status === 'OK' && (
        <ul className={styles.suggestionWrapper}>{renderSuggestions()}</ul>
      )}
    </div>
  );
};

export default Home;
```

`Home.module.css`

```css
.homeWrapper {
  display: flex;
  justify-content: center;
  align-items: center;
}

.sidebar {
  margin-right: 16px;
  width: 20%;
  height: 100vh;
  background-color: #333;
}

.autocompleteWrapper {
  width: 100%;
  height: 100%;
}

.autocompleteInput {
  width: 96%;
  margin: 84px auto 0 auto;
  padding: 16px;
  display: block;
  border: 1px solid yellow;
}

.suggestionWrapper {
  margin: 0;
  width: 96%;
  overflow-x: hidden;
  list-style: none;
  margin: 0 auto;
  display: block;
  padding: 4px;
}

.suggestionWrapper > li {
  padding: 8px 4px;
  background-color: lightcoral;
  margin: 4px 0;
  cursor: pointer;
}
```