Skip to content
Lovable Field Guide
Start here

Local SEO for a Lovable site: what the build can and can't do

Lovable local SEO in practice: your Business Profile does the ranking, and the site supports it with schema crawlers receive, safe location pages and grid tracking.

15 min read

Your Google Business Profile does most of the local ranking work. The website you built in Lovable is the supporting asset: it confirms who you are, where you operate and what you sell, and it carries the service and location pages that win the organic results sitting underneath the map pack. Getting the site right will not rescue a weak profile, and a strong profile will still leak organic traffic if the site is thin, unlinked or invisible to crawlers. The Lovable SEO guide works through rendering and indexing in general; this is the local-specific part.

The short version: pick the right primary category on the profile, make one canonical name-address-phone string and use it everywhere, ship server-rendered LocalBusiness JSON-LD, build one excellent page per service before you build a single city page, link those pages through a real browsable hub, and stop reading average rank numbers.

Which parts of local ranking your site actually touches

Google publishes three local ranking factors and no weights: relevance (“how well a Business Profile matches what someone is searching for”), distance, and prominence, which Google describes as how well-known a business is, with links and reviews named as inputs. Google’s own guidance is thinner than most SEO writing implies, and it says flatly that there is no way to request or pay for a better local ranking. Every percentage weight you have read comes from a practitioner survey. Google doesn’t publish weights.

The most useful of those surveys is Whitespark’s 2026 Local Search Ranking Factors, which polled 47 practitioners. Its Local Pack top five, in order: primary GBP category, proximity of address to the point of search, keywords in the GBP business title, physical address in the city of search, and whether the business is open at the time of search. Your website appears nowhere in that list. Its Local Organic top five: a dedicated page for each service, geographic keyword relevance of content, quality and authority of inbound links, keywords in the GBP landing page title tag, and quantity of inbound links from industry-relevant domains.

That split is the whole strategy. Map pack placement is mostly profile and geography. Organic placement (the ten blue links below the pack, and the queries where no pack appears at all) is where the Lovable build earns its keep.

If you are a service-area business with no storefront, know the constraints first. Google requires that you remove your address if you do not serve customers there, caps you at 20 service areas, expects the overall area to stay within roughly two hours’ driving time, and defines areas by city or postal code rather than radius. Whitespark’s panel meanwhile puts both “physical address in city of search” and “address is showing on GBP” in the Local Pack top ten. Service-area businesses are structurally disadvantaged in the pack by design: plan to win on organic results and reviews.

NAP consistency: fix it once, then stop

Decide on one string for your business name, address and phone number, matching the profile character for character, and use it in the footer, on the contact page, on every location page and in your schema. Render it as selectable text and wrap the phone in a tel: link. Text inside an image or a map tile can’t be read. Inconsistent data causes duplicate listings, entity confusion and verification friction.

Then stop. Not one citation or NAP factor appears in either of Whitespark’s 2026 top tens. (AI answer engines are a different story: here’s how brands get cited in LLM answers.) Building hundreds of directory listings is low-leverage next to category selection, reviews, service pages and links.

LocalBusiness JSON-LD that crawlers actually receive

Google requires only two properties on LocalBusiness: name and address. Everything else (geo, telephone, openingHoursSpecification, priceRange, url, image) is recommended. Use the most specific schema.org subtype that fits (Plumber, Dentist, HVACBusiness, RoofingContractor, Electrician) rather than the bare LocalBusiness type.

Here is a complete, correct block for a storefront business that also travels to customers:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Plumber",
  "@id": "https://example.com/#business",
  "name": "Northgate Plumbing",
  "url": "https://example.com/",
  "telephone": "+1-206-555-0142",
  "email": "hello@example.com",
  "image": [
    "https://example.com/img/storefront-1x1.jpg",
    "https://example.com/img/storefront-4x3.jpg",
    "https://example.com/img/storefront-16x9.jpg"
  ],
  "logo": "https://example.com/img/logo.png",
  "priceRange": "$$",
  "paymentAccepted": "Cash, Credit Card, Invoice",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "1420 N 45th St Suite 3",
    "addressLocality": "Seattle",
    "addressRegion": "WA",
    "postalCode": "98103",
    "addressCountry": "US"
  },
  "geo": {
    "@type": "GeoCoordinates",
    "latitude": 47.66131,
    "longitude": -122.33745
  },
  "openingHoursSpecification": [
    {
      "@type": "OpeningHoursSpecification",
      "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
      "opens": "07:00",
      "closes": "18:00"
    },
    {
      "@type": "OpeningHoursSpecification",
      "dayOfWeek": "Saturday",
      "opens": "08:00",
      "closes": "14:00"
    }
  ],
  "areaServed": [
    { "@type": "City", "name": "Seattle" },
    { "@type": "City", "name": "Shoreline" },
    { "@type": "City", "name": "Edmonds" }
  ],
  "sameAs": [
    "https://www.facebook.com/northgateplumbing",
    "https://www.yelp.com/biz/northgate-plumbing-seattle"
  ]
}
</script>

Details that matter, all traceable to Google’s LocalBusiness reference:

  • geo should carry at least five decimal places. Google says so explicitly.
  • priceRange is capped at 100 characters.
  • Closed all day Sunday? Omit it. For a seasonal or temporary closure, use validFrom and validThrough on an OpeningHoursSpecification entry.
  • Plain day strings work. Google’s own example uses the plain "Monday" form.
  • @id gives the entity a stable identifier so other nodes (a Service, a BreadcrumbList, a parent Organization) can reference it instead of duplicating the address.
  • Your hours here must match the profile. “Business is open at time of search” is a top-ten pack factor, and contradictory hours between the two is an avoidable entity conflict.

For a service-area business, keep address but drop streetAddress, leaving locality, region, postal code and country. Then express coverage with areaServed, either as the list of cities above or as a circle:

"areaServed": {
  "@type": "GeoCircle",
  "geoMidpoint": {
    "@type": "GeoCoordinates",
    "latitude": 45.51223,
    "longitude": -122.65801
  },
  "geoRadius": "48000"
}

geoRadius is in meters. 48000 is about 30 miles; writing "30" silently declares a 30-meter service area, which is the most common bug in this block. Two honest caveats: areaServed supersedes the older serviceArea property, so use areaServed for new work, and it is not one of Google’s documented properties for LocalBusiness rich results. It describes your entity. It does not earn you anything in the SERP.

Why a fully server-rendered location page can still ship without your NAP

Lovable changed its default stack on 13 May 2026. Projects created since then are TanStack Start on Cloudflare Workers, where server rendering is on by default. Older projects are React + Vite, client-rendered, with Lovable applying on-request pre-rendering on deployed public URLs that is served only to verified crawlers: Google, Bing, social bots, and AI engines.

Server rendering doesn’t settle the question, though. SSR renders your components, and it only includes data that a route loader fetched. React’s docs are explicit that effects “only run on the client. They don’t run during server rendering”, so the server renders the component in its initial state and the HTML ships whatever the loading branch produces. TanStack Query’s SSR guide says the same thing about queries you do not prefetch: they “wont be server rendered, instead they will be fetched on the client after the application is interactive.” Lovable’s own description of the new stack is conditioned on exactly this point: the server “runs the React tree, executes the loaders, and streams the result.” Loaders. That is the hinge.

So “SSR is enabled” and “my address is in the HTML” are two independent facts, and on a location page the gap between them is the whole story. Local pages are unusually likely to load their content dynamically: a locations table with a row per branch, and the page rendering name, street address, phone and hours from that row. If the row is fetched in a route loader or a createServerFn the loader awaits, the data is serialized into the HTML and every crawler gets it. If it is fetched in a useEffect or an un-prefetched useQuery inside the component, the server response contains a perfectly rendered page skeleton with an empty address line, on a page whose entire job is to state your NAP. The schema block and the visible NAP go missing together, for the same reason.

If your JSON-LD is injected by JavaScript, three things happen at once:

  1. Googlebot can read it, less reliably. Google documents injecting markup with custom JavaScript as a supported approach, and says Search understands structured data available in the DOM when it renders the page, while warning in the same document that dynamically generated markup can make crawls less frequent and less reliable.
  2. AI crawlers probably never see it at all. Vercel’s December 2024 log study of real traffic across its network found that none of the major AI crawlers render JavaScript, and a July 2026 RESONEO investigation of ChatGPT’s retrieval stack reported the same. No AI vendor documents this behavior either way, and both sources have a commercial stake in the conclusion, so treat it as an observed property rather than a stated one, but plan for it.

The failure that actually bites is specific: JSON-LD assembled inside a useEffect that waits on a fetch:

// Broken: the schema exists only after the API call resolves.
useEffect(() => {
  fetch(`/api/locations/${slug}`)
    .then((r) => r.json())
    .then((loc) => {
      const el = document.createElement('script')
      el.type = 'application/ld+json'
      el.text = JSON.stringify({
        '@context': 'https://schema.org',
        '@type': 'Electrician',
        name: loc.name,          // empty at first paint
        telephone: loc.phone,    // empty at first paint
      })
      document.head.appendChild(el)
    })
}, [slug])

Google may snapshot the DOM before that promise resolves and index valid JSON-LD describing nothing. It validates perfectly in your browser, so nothing warns you. Move the fetch instead of moving the script tag: on TanStack Start, load the row in the route’s loader (or a createServerFn the loader awaits) and render the <script> from that data, because loader data is serialized into the HTML and component-hook data is not. On the older Vite stack the same rule applies to the snapshot: whatever is in the DOM when the page is captured is what the verified crawler receives. As of September 2026 Lovable’s docs don’t say whether that pre-render waits for in-flight fetches, so build the block from data the page already holds and verify with the checks below rather than assuming.

Verify against the raw HTTP response with curl, using a location page:

URL="https://example.com/locations/tacoma/"

# 1. Is there any JSON-LD in the server response at all?
curl -sL "$URL" | grep -c 'application/ld+json'

# 2. Does the address exist in raw HTML, before JavaScript runs?
curl -sL "$URL" | grep -qiF '902 Pacific Ave' \
  && echo 'server-rendered' \
  || echo 'client-fetched, not in the HTML crawlers read'

# 3. Compare response sizes with and without a Googlebot user agent.
curl -sL "$URL" | wc -c
curl -sL -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" "$URL" | wc -c

Five things to know before you read those numbers:

  • The search phrase in step 2 must be one contiguous run of text. "Northgate Plumbing, 902 Pacific Ave" fails against markup that wraps the address in its own element, even when the address is sitting right there.
  • A byte count includes inline scripts and CSS, so a large response is not evidence that your content is in it. Step 2 is the one that answers the question; step 3 only tells you whether the server treats the two requests differently.
  • On the React + Vite stack, these checks can’t show what Google receives. Lovable’s docs say pre-rendered HTML goes only to crawlers it verifies, and that can’t be verified from outside. Use Search Console’s URL Inspection tool for that stack.
  • If the two sizes come back identical, that on its own is ambiguous: cached, prerendered and simply deterministic all look the same. Read cf-cache-status and age from curl -sI "$URL" before drawing a conclusion.
  • A hit is not always the whole story. With streaming SSR, content resolved after the shell can land in a <div hidden id="S:0"> followed by a $RC(...) script that moves it into place on the client. The bytes contain your address, but a crawler that does not execute that script may not read it as page content. When it matches, also check where in the document the match sits.

On the older React + Vite stack, a Screaming Frog or Ahrefs crawl showing an empty <div id="root"> is expected behavior. Third-party scanners are deliberately left off Lovable’s verified list. Test structured data through the Rich Results Test by entering the URL. Google notes that pasting code has JavaScript limitations such as CORS restrictions.

The durable fix is to load location data in the route’s loader, so the address, hours and JSON-LD are in the first response. Where that refactor isn’t practical, a pre-rendering service such as Encited can render the page in a headless browser and serve the finished HTML to crawlers.

Location and service pages that are not doorway pages

Google’s spam policies define doorway abuse as pages “created to rank for specific, similar search queries”, and its own listed examples include multiple domains or pages for specific regions and cities funneling to one page. The underrated clause in the same policy is the last one: “substantially similar pages positioned closer to search results than a clear browseable hierarchy”. Pages that exist only as SERP entry points, reachable from nothing, are already a listed signal.

In 2026 the policy more likely to catch a template-generated city page set is scaled content abuse, defined as generating many pages primarily to manipulate rankings rather than help users. Its first listed example is using generative AI tools to generate many pages without adding user value. Unlike doorway abuse, it does not require the pages to funnel anywhere: volume plus thinness is enough. For anyone about to ask Lovable to spin out 200 city pages from one component, that is the relevant rule.

The test is whether each page carries substance that would be false anywhere else.

Survives Reads as scaled content
Real local pricing, real permit or code detail, real response times The same copy with the city name swapped
Photos of actual jobs in that area, with the address or neighborhood named Stock imagery, or a landmark photo with no connection to the work
Named staff who cover that area, and the local phone number One central phone number on every page
Its own LocalBusiness or Service schema with that location’s data One site-wide schema block copied to every page
Unique <title> and <h1> written for that page One templated title pattern stamped across 70 URLs
Reachable from a /locations/ hub and from the nav Present only in the sitemap

A heuristic worth adopting, though it is practitioner consensus rather than Google doctrine: if you cannot write 300 words about a location that would be untrue for every other location, do not make the page.

Sequence matters too. Whitespark’s top Local Organic factor is a dedicated page for each service, with geographic relevance second. A business with six services across twelve cities should build six genuinely good service pages before it builds one city page, not 72 service-by-city combinations, which is exactly the shape scaled content abuse describes.

Google’s doorway policy names browsability explicitly, so the internal linking is not decoration. Build a real /locations/ hub that lists every location page as a plain <a href>, have each location page link back to the hub and across to the two or three nearest locations, and put every service page in the nav. Whitespark’s panel ranks internal linking across the website as the #6 Local Organic factor, and it is the one on this list you control completely.

One Lovable-specific trap: generated code frequently navigates with onClick handlers calling the router’s imperative API instead of rendering an anchor. Crawlers do not click. A location hub built from <div onClick={() => navigate(...)}> discovers nothing, and every page behind it is an orphan. Making a Lovable app crawlable covers that failure and the rest of the link layer in detail.

Add a BreadcrumbList to each location page so the hierarchy is machine-readable too:

{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com/" },
    { "@type": "ListItem", "position": 2, "name": "Locations", "item": "https://example.com/locations/" },
    { "@type": "ListItem", "position": 3, "name": "Tacoma" }
  ]
}

The final item deliberately omits item: that is Google’s documented convention for the current page.

Reviews and citations, in proportion

Reviews are the part of prominence you can influence. Whitespark’s Local Pack top ten includes both high Google ratings and quantity of native Google reviews, and Google’s own guidance says more reviews and positive ratings can help local ranking. Build review requests into a flow your Lovable app already owns (the post-job confirmation email, the booking follow-up) and link to the Google review form rather than a page of on-site testimonials.

Treat the circulating review-recency statistics carefully: the most-quoted figures come from BrightLocal’s consumer survey, which blocked automated fetching during our research, so we could not verify them at source. Citations get whatever time is left over: fix the wrong addresses and dead phone numbers on the handful of aggregators that feed everything else, then move on.

Why your average local rank is a lie

Distance from the searcher is an official Google ranking factor, computed from device location or, when the query names a place, from a city centroid. Rank therefore varies street by street. A single tracked position is one sample from one coordinate, and it describes no real searcher’s experience.

Geo-grid tracking exists to fix exactly that. Local Falcon, BrightLocal, Places Scout and Whitespark all sell it, and the mechanism is the same: the tool queries the map pack from a lattice of simulated coordinates around your pin (commonly a square grid at a fixed spacing) and renders each point’s rank as a colored marker. What you get is a map of where you’re visible, with a shape and an area.

The difference matters. A business can average “position 8” while ranking first within half a mile of its pin and not ranking at all beyond three miles. That average is also unstable for reasons unrelated to your SEO: widen the grid, change the spacing or move the center point and the average moves with it. Fix the configuration once and never change it, or your trend line is measuring your tool.

Look at the whole map. The average hides where you lose. Area is how far out you rank at all, and it grows when prominence grows. Shape matters because asymmetry usually means a competitor dominates one side or your pin placement is off. Change at a fixed config is the only comparison that means anything.

Grids have a real limitation worth stating: they show you where you are weak and leave the why to you. A grid tells you to investigate the north-east corner; it does not tell you that the competitor there has 400 reviews and you have 40.

Where AI search fits, and where it does not

The panic is mostly misplaced for local businesses. Whitespark’s May 2025 study of 540 queries across three cities and six industries found AI Overviews on 68% of queries overall, but split by intent the picture inverts: on simple local-intent queries AI Overviews appeared 15% of the time while local packs appeared 93%, and on informational queries AI Overviews appeared 92% while packs appeared 6%. Collection was manual and the researchers were not physically in those markets, so treat the figures as directional. The practical read: “plumber near me” is still a map pack game, and “how much does repiping cost in Phoenix” is a content game.

One thing carries over from the schema discussion. If the log studies are right that AI crawlers read raw HTML without executing JavaScript, the visible text of your location pages matters more to them than your structured data, and RESONEO’s July 2026 investigation reports that ChatGPT strips JSON-LD entirely when converting pages to Markdown. Put the address, phone number, hours and service detail in the visible page markup of the server response, in addition to any JSON-LD. Getting a Lovable site visible in AI search goes further into that.

What to do next

  1. Open your Business Profile and check the primary category against what your best-ranking competitor uses. It is the single strongest pack factor and it takes a minute to change.
  2. Run the curl checks above against one location page. If your street address is not in the raw HTML, move that fetch into the route loader before touching anything else.
  3. Write one canonical NAP string and reconcile the footer, contact page, schema and profile against it.
  4. Move your LocalBusiness JSON-LD into the server response, drop any aggregateRating built from your own testimonials, and check geoRadius is in meters.
  5. Count your service pages. If you have fewer of them than you have city pages, you built the site backwards: fix that order before publishing anything else.
  6. Take one geo-grid scan, write down the configuration, and never change it. Treat the first scan as a baseline.

Found something wrong or out of date? Lovable changes fast and we'd rather fix a guide than let it rot.

Disclosure: the team behind this guide also builds Encited, mentioned above.

Frequently asked questions

Will LocalBusiness schema give my site star ratings in Google?
Not from your own testimonials. Google's review snippet policy states that if the entity being reviewed controls the reviews about itself, its pages using LocalBusiness or Organization structured data are ineligible for the star review feature. That covers reviews hosted on your own site and embedded third-party review widgets. Adding aggregateRating built from your own testimonials produces no stars and risks a structured data manual action.
Can Google read JSON-LD that my React app injects after the page loads?
Yes. Google's documentation says Search can understand structured data available in the DOM when it renders the page. But Google also warns that dynamically generated markup can make crawls less frequent and less reliable, and if your script waits on an async fetch, Google may snapshot the DOM before the data arrives and capture empty values. No AI vendor documents its own rendering behavior, but third-party log studies (Vercel's from December 2024 and RESONEO's from July 2026) report that GPTBot, ClaudeBot and PerplexityBot read raw HTML without executing JavaScript, so plan for them not seeing it.
My Lovable project uses the new TanStack Start stack. Is my location data in the HTML?
Not automatically. Server rendering renders your components, and it only includes data that a route loader fetched. TanStack Query's SSR guide states that queries you do not prefetch are not server rendered and are instead fetched on the client after the application is interactive, so a location page can be fully server-rendered and still ship an empty address line. What decides it is where the fetch lives: a route loader or createServerFn puts the row in the HTML, a useEffect or un-prefetched useQuery inside the component does not. Curl the page and grep for your street address to find out which one you have.
How many city pages can I publish before Google treats them as spam?
There is no number. Google's spam policies name two relevant behaviors: doorway abuse, which explicitly includes multiple pages for specific regions or cities funneling to one page, and scaled content abuse, whose first listed example is using generative AI tools to generate many pages without adding user value. The practical test is whether each page has substance of its own. If you cannot write several hundred words about a location that would be false for any other location, do not publish the page.
Should I put my city and service in my Google Business Profile name?
No, even though it works. Whitespark's 2026 survey of 47 local search practitioners ranks keywords in the GBP business title as the third strongest Local Pack factor, but Google's representation guidelines explicitly forbid service descriptors and location references in the business name. Using one invites competitor redressal reports and suspension. Use your real-world name in both the profile and your schema.
Why does my local rank look different on my phone than in my rank tracker?
Because distance from the searcher is one of Google's three stated local ranking factors, so the map pack is computed per location. A single tracked position is one sample from one coordinate. A geo-grid scan queries the pack from a lattice of simulated coordinates instead, which is why the same business can sit at number one near its pin and be absent three miles away.

Read next