Google Map Places Autocomplete with Next.js

Google APIs documents are annoying to read, and they lack support for popular frameworks such as React and Next.js. In this article I’ll show you how I implemented lazy loading the API the simplest way possible.
I have made my own custom react hook for checking whether Google Map API script needs to be loaded or not.
./hooks/use-google-map-api.tsx
'use client';
import * as React from 'react';
interface State {
needGoogleMapApi: boolean;
isReady: boolean;
}
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { needGoogleMapApi: false, isReady: false };
let firstTime = true;
function dispatch(updates: Partial<State>) {
memoryState = { ...memoryState, ...updates };
listeners.forEach((listener) => {
listener(memoryState);
});
}
export function useGoogleMapApi(autoLoad: boolean = false) {
const [state, setState] = React.useState<State>(() => {
// Synchronously set needGoogleMapApi if autoLoad is true (genius)
if (autoLoad && !memoryState.needGoogleMapApi) {
memoryState.needGoogleMapApi = true;
}
return memoryState;
});
React.useEffect(() => {
listeners.push(setState);
if ((autoLoad && !memoryState.needGoogleMapApi) || firstTime) {
dispatch({ needGoogleMapApi: true });
firstTime = false;
}
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [autoLoad]);
const loadGoogleMap = React.useCallback(() => {
if (!memoryState.needGoogleMapApi) {
dispatch({ needGoogleMapApi: true });
}
return state;
}, [state]);
const onReady = React.useCallback(() => {
dispatch({ isReady: true });
}, []);
return {
...state,
loadGoogleMap,
onReady,
};
}
The component that is responsible for loading the Google Script tag
./components/google-map-api.tsx
'use client';
import { useGoogleMapApi } from '@/hooks/use-google-map-api';
import Script from 'next/script';
export function GoogleMapApi() {
const { needGoogleMapApi, onReady } = useGoogleMapApi();
if (!needGoogleMapApi) return null;
// Load Google Places API
return (
<Script
defer
src={`https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}&libraries=places`}
onReady={onReady}
/>
);
}
You just need to load this file in the layout.tsx
and you’re good to go:
import { GoogleMapApi } from '@/components/google-map-api';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<GoogleMapApi />
</body>
</html>
);
}
Now if you just add the following code to a page that you want to use the API:
const { isReady } = useGoogleMapApi(true);
Or you could lazily load it by doing so:
const { isReady, loadGoogleMap } = useGoogleMapApi(true);
useEffect(() => {
loadGoogleMap();
}, [])
Here is an example of my app and my UI, we’ll be using the package use-places-autocomplete
for easy integration (also assume DasiyUI v4 and Tailwindcss v3 is installed):
npm i use-places-autocomplete
LocationInput.tsx
import { useGoogleMapApi } from '@/hooks/use-google-map-api';
import classNames from 'classnames';
import { useEffect, useId, useRef, useState } from 'react';
import usePlacesAutocomplete from 'use-places-autocomplete';
export function LocationInput({
defaultValue,
onChange,
disabled,
className,
...props
}: {
defaultValue: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
} & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'value' | 'defaultValue' | 'disabled' | 'className'
>) {
const inputRef = useRef<HTMLInputElement>(null);
const { isReady } = useGoogleMapApi(true);
const [trackedValue, setTrackedValue] = useState(defaultValue);
const [selectedIndex, setSelectedIndex] = useState(-1);
const uniqueId = useId();
const listId = `location-list-${uniqueId}`;
const {
ready,
value,
suggestions: { status, data },
setValue,
clearSuggestions,
init,
} = usePlacesAutocomplete({
debounce: 100,
initOnMount: false,
});
useEffect(() => {
if (isReady) init();
}, [isReady, init]);
// Custom validation for the input
useEffect(() => {
if (trackedValue) inputRef.current?.setCustomValidity('');
else inputRef.current?.setCustomValidity('You Must Select a Location');
}, [trackedValue, setValue]);
useEffect(() => {
onChange(trackedValue);
}, [trackedValue, onChange]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (status !== 'OK') return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, data.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, -1));
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0) {
const suggestion = data[selectedIndex];
setTrackedValue(suggestion.description);
setValue(suggestion.structured_formatting.main_text, false);
clearSuggestions();
setSelectedIndex(-1);
}
break;
case 'Escape':
clearSuggestions();
setSelectedIndex(-1);
break;
}
};
return (
<div className={classNames('dropdown', className)}>
<input
ref={inputRef}
type="text"
className="!input !input-bordered w-full invalid:input-error"
value={!ready ? 'Loading...' : value || trackedValue}
onChange={(e) => {
setValue(e.target.value);
setTrackedValue('');
setSelectedIndex(-1);
}}
onKeyDown={handleKeyDown}
disabled={!ready || disabled}
placeholder={'Enter a location'}
role="combobox"
aria-expanded={status === 'OK'}
aria-controls={listId}
aria-label="Location search"
aria-autocomplete="list"
{...props}
/>
{/* We can use the "status" to decide whether we should display the dropdown or not */}
{status === 'OK' && (
<ul
id={listId}
role="listbox"
className="menu dropdown-content z-50 w-full overflow-x-auto rounded-box bg-base-100 p-2 shadow-2xl lg:w-screen lg:max-w-96"
>
{data.map((suggestion, index) => {
const {
place_id,
description,
structured_formatting: { main_text, secondary_text },
} = suggestion;
return (
<li
key={place_id}
role="option"
aria-selected={index === selectedIndex}
className={classNames(index === selectedIndex && 'bg-base-200')}
// Prevent the menu from disappearing when clicking on the suggestion
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setTrackedValue(description);
// When the user selects a place, we can replace the keyword without request data from API
// by setting the second parameter to "false"
setValue(main_text, false);
clearSuggestions();
setSelectedIndex(-1);
}}
>
<p className="flex flex-col items-start gap-0">
<strong>{main_text}</strong> <small>{secondary_text}</small>
</p>
</li>
);
})}
</ul>
)}
</div>
);
}
Subscribe to my newsletter
Read articles from Bill Zhang directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
