RA
RenovateAPIEngineering Hub
Frontend Engineering

Building a Google Maps Business Location Picker in React (With Nearby Business Search)

A practical React + TypeScript guide to a Google Maps location picker: reverse geocoding, nearby business discovery via Places API (New), custom markers, and the errors that trip everyone up.

Frontend Engineering12 min read

Building a Google Maps Business Location Picker in React (With Nearby Business Search)

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE

If you're building a business directory, a local-services marketplace, or a CRM with a "select your business location" step, you'll hit this requirement fast: let someone search a location, drop a pin, get the address automatically, and pick from businesses nearby. I built exactly this for a local-SEO product, and the annoying part isn't the map — it's the three separate Google APIs that all look like they should be one thing.

Here's the full flow, and where people usually get stuck.

User searches a location → map moves → user drops a pin
  → reverse geocode → address/city/state/PIN
  → nearby Places search → business markers appear
  → user picks a business → form auto-fills

Set up Google Cloud before you write a line of React

You need three APIs enabled on the same project, with billing attached:

API What it actually does
Maps JavaScript API Renders the map itself
Geocoding API Coordinates ↔ address, both directions
Places API (New) Finds businesses near a point

Miss one of these and you'll get a runtime error that looks like a bug in your code but is really just a missing checkbox in Cloud Console. More on that below.

Restrict the API key immediately, before you write anything else:

http://localhost:5173/*
http://localhost:3000/*
https://example.com/*
https://www.example.com/*

HTTP referrer restrictions plus API restrictions (limit the key to only the three APIs above) — that's the whole security model for a browser key. And that's worth sitting with for a second: a Google Maps key embedded in frontend code is not a secret, and it was never meant to be one. The security lives in the restrictions you set in Cloud Console, not in keeping the string hidden. People burn hours worrying about a leaked key in their JS bundle when the actual fix is checking whether referrer restrictions are on.

The error you'll hit first

Google Maps JavaScript API error:
ApiNotActivatedMapError

This just means the Maps JavaScript API isn't enabled on the project your key belongs to. A close cousin:

Geocoding Service:
This API is not activated on your API project.

Same root cause, different API. If you're stuck here, check five things in order: the key belongs to the right project, billing is on, no API restriction is silently blocking the call, your origin is in the allowed referrer list, and — the one everyone forgets — you restarted the dev server after touching .env. Vite and Next.js both cache env vars at boot; editing the file does nothing until you restart.

Don't hardcode a fallback key

This pattern shows up in almost every tutorial repo:

apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "AIzaSyxxxxxxxxxxxxxxxx";

The || fallback is the problem. It feels harmless in dev and then someone forgets to set the env var in staging, ships it, and now there's a real key sitting in a public repo's git history forever. Just don't give it a fallback:

apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}

Vite reads VITE_GOOGLE_MAPS_API_KEY, Next.js wants the NEXT_PUBLIC_ prefix instead — NEXT_PUBLIC_GOOGLE_MAPS_API_KEY. If a real key ever does leak, rotate it in Cloud Console immediately and issue a replacement with proper restrictions. Don't try to "un-leak" it; assume it's compromised the moment it's public.

Rendering the map

google-map-react is a solid wrapper for this. Keep the map's own state — center, zoom, marker position — separate from your application/form state. They change for different reasons and coupling them makes the component harder to reason about later.

<GoogleMapReact
  bootstrapURLKeys={{ key: apiKey, libraries: ["places"] }}
  center={center}
  zoom={zoom}
  yesIWantToUseGoogleMapApiInternals
  onGoogleApiLoaded={handleGoogleApiLoaded}
>
  {/* markers go here */}
</GoogleMapReact>
const [center, setCenter] = useState({ lat: 19.076, lng: 72.8777 });
const [markerPos, setMarkerPos] = useState({ lat: 19.076, lng: 72.8777 });
const [zoom, setZoom] = useState(15);

Turning a click into an address: reverse geocoding

A map click gives you raw coordinates. Reverse geocoding turns 19.076, 72.8777 into something like "Mumbai, Maharashtra 400001, India." The Geocoder lives on the maps object your wrapper exposes once the API's loaded:

const handleGeocode = (lat: number, lng: number, mapsObj?: any) => {
  const maps = mapsObj || mapsApi?.maps || (window as any).google?.maps;
  if (!maps?.Geocoder) return;

  const geocoder = new maps.Geocoder();

  geocoder.geocode({ location: { lat, lng } }, (results: any[], status: string) => {
    if (status !== "OK" || !results?.length) return;
    const result = results[0];
    // result.formatted_address, result.place_id, result.address_components
  });
};

The response won't give you clean city / state / pinCode fields — it gives you address_components, an array where each entry has a types array telling you what kind of thing it is. You write a small parser once and reuse it everywhere:

const parseAddressComponents = (components: any[]) => {
  let city = "", state = "", pinCode = "";

  for (const component of components || []) {
    const types = component.types || [];
    if (types.includes("locality")) city = component.long_name;
    if (!city && types.includes("administrative_area_level_2")) city = component.long_name;
    if (types.includes("administrative_area_level_1")) state = component.long_name;
    if (types.includes("postal_code")) pinCode = component.long_name;
  }

  return { city, state, pinCode };
};

The administrative_area_level_2 fallback matters — in a lot of Indian addresses, locality comes back empty and the city name only shows up one level up. Skip that fallback and you'll get silently blank city fields for a chunk of your users.

Reverse geocoding won't get you businesses — that's a different API

This trips people up constantly: geocoding tells you where a coordinate is, not what's around it. For business discovery you need the Places API (New) and its Place.searchNearby() method.

interface BusinessPlace {
  id: string;
  name: string;
  address?: string;
  lat: number;
  lng: number;
  rating?: number;
  types?: string[];
}
const searchNearbyBusinesses = async (lat: number, lng: number) => {
  try {
    setLoadingBusinesses(true);
    const maps = mapsApi?.maps || (window as any).google?.maps;
    if (!maps) return;

    const { Place } = await maps.importLibrary("places");

    const { places } = await Place.searchNearby({
      fields: ["id", "displayName", "formattedAddress", "location", "businessStatus", "types", "rating"],
      locationRestriction: { center: { lat, lng }, radius: 1000 },
      includedTypes: ["restaurant", "cafe", "store", "shopping_mall", "beauty_salon", "gym", "hospital", "pharmacy"],
      maxResultCount: 20,
    });

    const businesses = (places || [])
      .filter((place: any) => place.location)
      .map((place: any) => ({
        id: place.id,
        name: place.displayName || "Unnamed business",
        address: place.formattedAddress || "",
        lat: place.location.lat(),
        lng: place.location.lng(),
        rating: place.rating,
        types: place.types || [],
      }));

    setBusinesses(businesses);
  } catch (error) {
    console.error("Nearby Places error:", error);
    setBusinesses([]);
  } finally {
    setLoadingBusinesses(false);
  }
};

Two things worth calling out on the request itself. radius: 1000 is roughly a 1km search — give users a choice between 500m, 1km, 2km, and 5km rather than defaulting to something huge; a bigger radius returns more data than your UI needs and costs more per call. And includedTypes matters more than it looks — searching every category at once is worse UX and worse performance than letting the user pick "Restaurants" or "Gyms" from a dropdown and scoping the request to just that type.

Wire the click handler to do both jobs at once:

const handleMapClick = ({ lat, lng }: { lat: number; lng: number }) => {
  setMarkerPos({ lat, lng });
  setCenter({ lat, lng });
  handleGeocode(lat, lng);
  searchNearbyBusinesses(lat, lng);
};

Markers: don't label everything permanently

The instinct is to put the business name directly on every marker. Do that in a dense commercial area and the map turns into unreadable text soup — twenty overlapping labels stacked on top of each other. Compact markers with the name on hover/click hold up much better:

const BusinessMarker: React.FC<{ name: string; rating?: number }> = ({ name, rating }) => (
  <div className="relative -translate-x-1/2 -translate-y-full cursor-pointer group">
    <div className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-lg border-2 border-red-500">
      <span className="material-symbols-outlined text-red-500">business</span>
    </div>
    <div className="absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded-md bg-white px-2 py-1 shadow-md border border-gray-200 opacity-0 group-hover:opacity-100">
      <div className="text-[11px] font-bold">{name}</div>
      {rating && <div className="text-[10px] text-gray-500">⭐ {rating}</div>}
    </div>
  </div>
);

Then let a scrollable list carry the detail the map can't:

Nearby Businesses (20)

Restaurant ABC          ⭐ 4.5
Mumbai, Maharashtra
[Select Business]

XYZ Salon                ⭐ 4.3
Mumbai, Maharashtra
[Select Business]

Twenty names on a card list beats twenty names crammed onto a map, every time. When a business gets picked, recenter the map on it and push the selection into whatever form you're driving:

const handleBusinessSelect = (business: BusinessPlace) => {
  setCenter({ lat: business.lat, lng: business.lng });
  setMarkerPos({ lat: business.lat, lng: business.lng });

  onSelectLocation({
    placeId: business.id,
    name: business.name,
    formattedAddress: business.address || "",
    city: "", state: "", pinCode: "",
    lat: business.lat,
    lng: business.lng,
  });
};

For a production build, fire a follow-up place-details request after selection to pull phone, website, and category — don't try to cram everything into the nearby-search response.

Google Place ID and OpenStreetMap ID are not interchangeable

If you're mixing providers — say, OpenStreetMap Nominatim as a geocoding fallback — watch for this: an OSM result looks like osm_249263607, and that string means nothing to any Google API expecting a Place ID. It's an easy mistake to make once and a genuinely confusing one to debug later, because both look like opaque IDs at a glance.

Nominatim can work as a fallback for basic address lookup, but mixing Google Maps + Google Places + Google Geocoding + OSM Nominatim gives you inconsistent IDs and a data model that's harder to reason about. If your product is fundamentally built on Google's business data, keep the stack pure — Google Maps, Google Geocoding, Google Places, nothing else. If provider independence or cost genuinely matters to you, build a deliberate abstraction layer instead of bolting on a fallback ad hoc.

Data model: store coordinates, not just the formatted address

interface BusinessLocation {
  name: string;
  googlePlaceId?: string;
  formattedAddress: string;
  city: string;
  state: string;
  postalCode: string;
  latitude: number;
  longitude: number;
  category?: string;
  rating?: number;
}

Don't use the formatted address string as your primary identifier for a location. Addresses get typo'd, reformatted, and duplicated across sources. Coordinates and the provider's own place ID are far more reliable for dedup and lookups later.

Structuring the component

One giant component that does geocoding, places search, and rendering is fine for a demo and a mess two months in. Split it:

components/
├── GoogleMapPicker.tsx
├── MapSearchInput.tsx
├── SelectedLocationMarker.tsx
├── BusinessMarker.tsx
├── NearbyBusinessList.tsx
└── BusinessDetailsCard.tsx

services/
├── googleMaps.ts
├── geocoding.ts
└── places.ts

types/
└── location.ts

Move the Google-specific logic out of the component and into services — it makes the component read like UI code again instead of a wall of API calls:

export async function reverseGeocode(maps: any, lat: number, lng: number) {
  const geocoder = new maps.Geocoder();
  return new Promise((resolve, reject) => {
    geocoder.geocode({ location: { lat, lng } }, (results: any[], status: string) => {
      if (status !== "OK") { reject(status); return; }
      resolve(results[0]);
    });
  });
}

Two search flows, not one

"Search a location" and "search for businesses" feel similar but resolve differently — worth keeping them mentally separate when you're wiring the search input.

Location search ("Borivali West") → geocode → coordinates → move the map → then search nearby. Business search ("restaurants in Borivali") → Places text search directly → markers + list, no geocode step needed.

And a misconception worth killing early: you cannot pull "every business currently on Google Maps" for an area. There's no such endpoint. The Places API expects you to specify a search area plus a type or query — includedTypes: ["restaurant"] or a text search like "restaurants near Borivali West." Don't design around scraping the rendered map UI; that's not what the API is for and it won't hold up.

Performance: don't search on every pixel of map movement

Firing a Places request on every dragend micro-event will burn through your quota fast and add nothing for the user. Debounce it — wait for the pan to actually stop, then search once. Beyond that: cap your radius, cap maxResultCount, request only the fields you need in fields, and don't reverse-geocode the same coordinates twice in a row. Small things, but they add up fast once you're past a handful of test users.

Before you ship

[ ] Rotate any exposed API key
[ ] Use environment variables, no hardcoded fallback
[ ] Restrict the key by HTTP referrer
[ ] Restrict the key to only the APIs you use
[ ] Billing configured
[ ] Production domain added to referrer list
[ ] Localhost restricted to dev environments only
[ ] Usage monitoring + budget alerts on in Cloud Console

Quick troubleshooting reference

Symptom Cause Fix
ApiNotActivatedMapError Maps JavaScript API disabled Enable it in Cloud Console
Geocoding Service: not activated Geocoding API disabled Enable it
Map loads, no businesses ever show Places API (New) disabled, or missing places library Enable Places API (New), check libraries: ["places"]
REQUEST_DENIED Key, billing, or restriction issue Check key validity, billing status, referrer restrictions
ID looks like osm_xxx That's OpenStreetMap, not Google Don't pass it where a Google Place ID is expected

The architecture, once it clicks, is genuinely simple: Maps JavaScript renders and handles interaction, Geocoding converts between coordinates and addresses, Places finds and identifies businesses. Three separate jobs. Keep them separate in your code the way Google keeps them separate in the API surface, and the rest of the component — search, markers, selection, form autofill — falls into place around that split instead of fighting it.

RenovateAPI Engineering Suite

Accelerate your Frontend Engineering Modernization Roadmap

Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?

Frequently Asked Questions

Does the Geocoding API return nearby businesses?

No. Reverse geocoding only converts coordinates into an address — city, state, postal code. It has no idea what businesses sit near that point. Business discovery is a separate call to the Places API (New), specifically Place.searchNearby().

Can I fetch every business currently visible on Google Maps?

Not as an open-ended pull. The Places API is built around search — you give it a location, radius, and optionally a category or query, and it returns matches. There's no endpoint for 'give me everything on the map,' and building around scraping the rendered UI will get you blocked.

Is it safe to expose a Google Maps API key in frontend code?

Yes, and it has to be — the browser needs it to call the API directly. The key itself isn't the secret. Your security comes entirely from HTTP referrer restrictions and API restrictions in Google Cloud Console, not from hiding the key.

Weekly Engineering Dispatch

Subscribe to RenovateAPI

Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.

Discussion (2)

A
Alex Rivera
2 hours ago

Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.

S
Sophia Chen
1 day ago

The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.

Suggested Related Articles