Skip to main content

Google Places Import

Seed a local directory from Google Places — scan by business type and area, import businesses as unclaimed listings with hours, photos and ratings, and keep them fresh with a daily refresh cron.

Why this matters

The hardest part of launching a local directory is the first thousand listings. Nobody claims a profile on an empty site, nobody requests a quote from a category with two businesses in it, and nobody links to a directory that lists nothing.

Import from Google fills the catalogue: you pick a business type and an area, it scans Google Places and imports the results as listings — with descriptions, photos, opening hours, coordinates, Google's rating and its reviews.

Imported listings are unclaimed on purpose

Every imported business is created unclaimed, which is what makes Listing Claims worth having on a seeded directory: the owner arrives, finds their business already there, and claims it. A catalogue of listings you submitted as yourself has nothing to claim.

Turn it on

1. Enable the flag in config/features.config.ts:

export const features = defineFeatures({
  places: true,
});

2. Add your keyGOOGLE_PLACES_API_KEY in your environment. Create it in the Google Cloud console with the Places API (New) enabled, and attach a billing account: Google bills per request, not per result.

3. Run migration 0015_add_google_places_fields.sql in the Supabase SQL Editor. It adds google_rating, google_ratings_total, google_reviews, google_types, google_maps_uri and places_synced_at to apps. See Supabase Setup.

4. Open /admin/places — the Import from Google screen.

Restrict the key before you deploy it

A Places key is a billable credential. Restrict it to the Places API and, on Vercel, keep it server-side only — it is read by the admin routes and the refresh cron, never by the browser. See Environment Variables.

How the scan actually works

Google caps a search at 20 results and never paginates past 60. "Every plumber in Chicago" is not a query anyone can make, which is why a naive importer returns twenty businesses and stops.

The scanner works around the cap geometrically:

StepWhat happens
TileYour area is covered with overlapping circular cells of cellRadiusMeters.
QueryEach cell is queried for each selected business type. This is the billable unit: requests = cells × types.
SubdivideA cell that comes back full (20 results) is Google's only signal that there were more. It is re-queried as four smaller cells at ±r/2 with radius r/√2, up to maxSubdivisionDepth.
DedupeResults are matched against your catalogue by place id, so re-running a scan shows what is new rather than a wall of duplicates.
ImportSelected results become listings. Importing a business you already have refreshes it instead of cloning it.

What it costs, before you press the button

Google bills per request against your card, and a thorough scan is hundreds of them. The scan builder shows cells, requests and dollars live as you drag the radius or change the data depth, names the SKU you are on, and tells you when the run fits inside the monthly free tier.

depthWhat you getSKU
basicName, address, coordinates, place idPro — about $32 per 1,000 requests
standardAdds phone, website, opening hours, ratingEnterprise — about $35 per 1,000
fullAdds reviewsEnterprise + Atmosphere — about $40 per 1,000

As a reference point, 168 cells at the Enterprise SKU is roughly $5.88. The estimate quotes the top-of-band rate and ignores volume discounts — over-estimating is the right direction to be wrong in when the number is a bill.

maxCellsPerRun is a spending limit, not a tuning knob

It exists so a radius typed with an extra zero cannot bill a fortune. Leave it low while you are learning the tool, and raise it once the estimate matches what you expect.

Configuration

Scan and import rules live in config/places.config.ts (yours, protected on merge — anything you leave out is inherited from config/defaults/places.defaults.ts, including the starter category map).

KeyDefaultWhat it does
depth'standard'Which field set to request — the SKU you are billed at.
cellRadiusMeters1500Radius of one scan cell. Smaller means fuller coverage and more requests; 1500 suits a dense city, raise it for rural areas.
maxCellsPerRun400Hard ceiling on cells in a single run.
adaptiveSubdivisiontrueRe-query full cells as four smaller ones. This is what gets you every business in an area rather than the first 20 per cell.
maxSubdivisionDepth2How many times a cell may split.
photoStorage'mirror'mirror copies photos into your own storage once, proxy fetches them on demand, none skips them.
maxPhotosPerPlace3Photos imported per business.
photoMaxWidthPx1200Width photos are requested at.
languageCode / regionCode'en' / nullResult language and region bias. null follows your key's default.
descriptionTemplatessee configCopy for listings imported without AI. {reviews_line} and {hours_line} are whole sentences the importer fills only when it has the data.
attributionText'Business data from Google Maps'Shown wherever imported Google content appears.
Photos are the part with a policy attached

mirror is the only option that keeps working long-term, because Google's photo names expire — but Google's terms restrict storing photo files. If you would rather stay inside the letter of them, use proxy or none. The attribution string is not decorative either: displaying Google content without it breaks the terms you accepted with the key.

Keeping an imported catalogue fresh

/api/cron/places-refresh runs daily at 04:00 UTC and re-syncs imported listings oldest first. Businesses move, change their hours and close — and Google's terms cap how long Place content other than the place id may be held, so refreshing is a compliance requirement as much as a quality one.

It is deliberately conservative:

  • Skips listings a claimant has locked — once an owner takes over their profile, Google stops being the source of truth.
  • Never overwrites names or descriptions you edited.
  • Purges the catalog cache tag when it actually changed something, so the public site reflects the change instead of serving hour-old counts.

See Cron Jobs for how cron endpoints are authenticated.

What imported data does to the catalogue

A seeded directory has no ratings of its own on day one, and a catalogue where every card shows "No ratings" reads as broken rather than as new. Three things account for that:

  • Cards fall back to Google's rating while ratings_count is 0, labelled "on Google". Your own visitors' ratings win the moment they exist.
  • "Top Rated" and "Most Reviewed" read average_rating first and fall back to google_rating / google_ratings_total, so sorting means something before the first visitor rates anything. Listings with neither sink — the data layer orders NULLs last.
  • business_type is left unset by the importer. It is your service-model axis — storefront, mobile service, home service — and writing Google's primaryType into it turns the Types menu into a worse copy of the category menu ("Roofing contractor 60" next to "Roofing 60"), wrong often enough to matter: Google files most air-conditioning firms under general_contractor. The raw list is kept in google_types, and if you classify by service model yourself, the menu comes back.

Cost and scale, in one line

Scan small, then widen

Run one business type over one neighbourhood first. You will see the real result count per cell, the real cost per run, and whether your cellRadiusMeters is sensible for that area — all for a few cents, before you point it at a metro.

Next: Listing Claims · Local Business Directory