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.
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 key — GOOGLE_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.
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:
| Step | What happens |
|---|---|
| Tile | Your area is covered with overlapping circular cells of cellRadiusMeters. |
| Query | Each cell is queried for each selected business type. This is the billable unit: requests = cells × types. |
| Subdivide | A 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. |
| Dedupe | Results are matched against your catalogue by place id, so re-running a scan shows what is new rather than a wall of duplicates. |
| Import | Selected 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.
depth | What you get | SKU |
|---|---|---|
basic | Name, address, coordinates, place id | Pro — about $32 per 1,000 requests |
standard | Adds phone, website, opening hours, rating | Enterprise — about $35 per 1,000 |
full | Adds reviews | Enterprise + 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.
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).
| Key | Default | What it does |
|---|---|---|
depth | 'standard' | Which field set to request — the SKU you are billed at. |
cellRadiusMeters | 1500 | Radius of one scan cell. Smaller means fuller coverage and more requests; 1500 suits a dense city, raise it for rural areas. |
maxCellsPerRun | 400 | Hard ceiling on cells in a single run. |
adaptiveSubdivision | true | Re-query full cells as four smaller ones. This is what gets you every business in an area rather than the first 20 per cell. |
maxSubdivisionDepth | 2 | How many times a cell may split. |
photoStorage | 'mirror' | mirror copies photos into your own storage once, proxy fetches them on demand, none skips them. |
maxPhotosPerPlace | 3 | Photos imported per business. |
photoMaxWidthPx | 1200 | Width photos are requested at. |
languageCode / regionCode | 'en' / null | Result language and region bias. null follows your key's default. |
descriptionTemplates | see config | Copy 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. |
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
catalogcache 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_countis 0, labelled "on Google". Your own visitors' ratings win the moment they exist. - "Top Rated" and "Most Reviewed" read
average_ratingfirst and fall back togoogle_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_typeis left unset by the importer. It is your service-model axis — storefront, mobile service, home service — and writing Google'sprimaryTypeinto 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 undergeneral_contractor. The raw list is kept ingoogle_types, and if you classify by service model yourself, the menu comes back.
Cost and scale, in one line
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.