If your store locator only renders locations after JavaScript runs, many crawlers will see an empty page. The fix is architectural: give every location its own server-rendered URL, link to all of them from plain HTML, and treat the interactive map as an enhancement on top. The widget is for humans. The static pages are for everything else.
This post covers the failure mode, a URL structure that works, and how to generate the pages without hand-maintaining hundreds of files.
What is wrong with a typical locator?
A common locator is one route, /locations, that loads an empty shell, calls an API, and draws pins and a list client-side. The search box filters in the browser. There are no individual location URLs, or they exist only as hash fragments.
For a person with a modern browser, it works. For a crawler, the page is a script tag and a spinner.
Do crawlers really not run JavaScript?
Some do, some do not, and the ones that do are slower about it. Googlebot renders JavaScript, but rendering can happen later than the initial crawl. Vercel published an analysis in late 2024 of AI crawler traffic across its network and found that the major AI crawlers it measured, including OpenAI's and Anthropic's, fetched JavaScript files but did not execute them.
So if you want ChatGPT, Claude, or Perplexity to know your store hours, those hours need to be in the HTML response.
What URL structure works best?
One indexable URL per location, organized in a shallow hierarchy:
/locations/ all regions
/locations/texas/ state or region
/locations/texas/austin/ city, lists stores
/locations/texas/austin/south-congress/ one store
Each store page should include, in server-rendered HTML:
- Business name, full address, and phone number.
- Hours, including a visible note for holiday hours.
- Services or departments specific to that store.
- A static map image or link, not only an embedded interactive map.
-
LocalBusinessJSON-LD that matches the visible content.
The region and city pages are crawl paths, so link to every child with a normal <a href>. Do not rely on a sitemap alone.
How do you generate the pages?
Treat location data as content with one source of truth, and build pages from it. With a framework that supports static generation or server rendering, this is a small amount of code. A Next.js example using the App Router:
// app/locations/[state]/[city]/[store]/page.tsx
import { getAllStores, getStore } from "@/lib/locations";
export async function generateStaticParams() {
const stores = await getAllStores();
return stores.map((s) => ({ state: s.state, city: s.city, store: s.slug }));
}
export const revalidate = 3600; // refresh hours hourly
export default async function StorePage({ params }) {
const { store: slug } = await params; // params is async in Next.js 15
const store = await getStore(slug);
return (
<main>
<h1>{store.name}</h1>
<address>{store.street}, {store.city}, {store.region} {store.postalCode}</address>
<a href={`tel:${store.phone}`}>{store.phoneDisplay}</a>
<ul>
{store.hours.map((h) => <li key={h.day}>{h.day}: {h.open} to {h.close}</li>)}
</ul>
</main>
);
}
Revalidation matters for hours. A statically built page that never refreshes will eventually show last year's holiday schedule.
Where should location data live?
In one system that feeds both your listings and your pages. The failure you want to avoid is a website that says 9 to 6 while Google says 8 to 5, because someone updated one and not the other.
Options, with their tradeoffs:
| Approach | Strength | Weakness |
|---|---|---|
| Headless CMS | Full control, fits your stack | You build the sync to listings yourself |
| Yext Pages | Tight link to Yext's listing data | Tied to Yext's platform and pricing |
| Synup locator and pages | Location pages and listings share one data source | Less flexible than a fully custom build |
| Custom database plus scripts | Cheapest at small scale | Maintenance grows with every location |
Synup and similar platforms also expose APIs, so a common pattern is to keep the platform as the source of truth and pull data into your own static build. That keeps full control of the front end without a second copy of hours to maintain.
How do you check that it works?
Fetch a store page the way a non-rendering crawler would:
curl -s https://example.com/locations/texas/austin/south-congress/ | grep -i "south congress"
If the address and hours are not in that raw response, a crawler that skips JavaScript will not see them either. Also run a sample of store URLs through Google's URL Inspection tool in Search Console to confirm Google renders and indexes them.
FAQ
Can I keep my interactive map?
Yes. Render it on top of the static list. Just make sure the list and links exist without it.
Is a sitemap of location URLs enough?
It helps discovery, but internal links from region and city pages are what give crawlers a clear path and context.
Should closed stores keep their pages?
Redirect them to the nearest open location or the city page, so old links and bookmarks still land somewhere useful.
How many locations before this matters?
Even a few. A single JavaScript-only locator page can hide every location's hours from crawlers that skip scripts.
Top comments (0)