If you built a site on Lovable and you're wondering why Google previews look broken, why social shares pull the wrong image, or why your SEO audit comes back with a score in the 60s, here's the honest answer: Lovable's default Vite + React setup ships with broken SEO. Not slightly broken. Every page on your site serves the homepage HTML to crawlers.
This isn't Lovable being negligent. It's just a hosting decision they made, baked in at the infra level, with no UI to override it. Most Lovable users never notice because most never run a real SEO audit. I did, and what follows is the full chronicle. Every diagnostic, every wrong turn, every fix that actually landed, and the workflow I use now to keep new content indexed properly going forward.
Read this if you're shipping a content site on Lovable. The pattern repeats. Save yourself the four hours.
The starting point
I ran two skills against creativecore.ai. Both are open-source Claude Code skills I built for this exact workflow:
ccai-seo-auditis the diagnostic. Crawls your full sitemap, runs 60+ checks per page, outputs a prioritized fix list. MIT-licensed, free.ccai-seo-setupis the implementation. Reads the audit report and generates paste-ready prompts for your hosting platform (Lovable, Vercel, Netlify, Cloudflare Pages, Webflow, Wix). Also free.
Both live in the larger ccai-skills-pack monorepo (34 Creative Core AI skills total, one-command installer).
Install with Claude Code:
git clone https://github.com/cory-dot/ccai-seo-audit ~/.claude/skills/ccai-seo-audit
git clone https://github.com/cory-dot/ccai-seo-setup ~/.claude/skills/ccai-seo-setup
Then run the audit:
/ccai-seo-audit https://creativecore.ai
This produced a report at audits/creativecore-ai-2026-05-12/REPORT.md. Headline finding:
Every URL on the site serves the same static index.html. Per-page metadata exists only after client-side JavaScript hydration via react-helmet-async.
Score: 64 / 100 overall, 51 / 100 for AEO readiness. Twelve fixes flagged. Then I ran:
/ccai-seo-setup vite-react audits/creativecore-ai-2026-05-12/REPORT.md
Which generated 13 Lovable-ready prompts in seo-setup/prompts/. The plan: paste each one into Lovable, verify with curl, move on. I expected a few hours. It took longer, and the reason is the story.
What I assumed vs. what was actually happening
I assumed Lovable's hosting was generic static hosting. Build the site, ship the dist/ folder, let the host figure out routing. This assumption was wrong in three specific ways, and I discovered each one only by running curls against the deployed site after each "fix" and watching them fail.
Discovery 1: Lovable's hosted build ignores your npm scripts
The first prompt was the simple one. Make the 404 page noindex itself. That landed cleanly. The second prompt was the foundational one: add static site generation so each page has its own HTML at build time. Lovable proposed extending an existing scripts/prerender-guides.ts (already in the codebase) to also emit static HTML for top-level routes. We did it. They published. I ran:
curl -s https://creativecore.ai/ | grep -i "<title>"
curl -s https://creativecore.ai/guides/which-claude-plan | grep -i "<title>"
curl -s https://creativecore.ai/products | grep -i "<title>"
All three returned the same homepage title. Nothing had changed. I went back to Lovable and asked. The answer:
Lovable's managed hosting builds with
vite builddirectly. It does not execute yourpackage.jsonbuild script. Anything chained aftervite build(yourroutes:prerender,guides:prerender,sitemap) never runs in production.
This was the first real reveal. The prerender scripts had been in this codebase the whole time. They had never run on the deployed site. They only ran in local dev and in Lovable's preview environment. Every other Lovable user with a "build": "vite build && something-else" setup has the same problem and probably doesn't know it.
The fix: move the prerender logic into a Vite plugin's closeBundle hook so it runs as part of vite build itself, not as a script chained after it. Same code, different invocation point. Lovable wrote the plugin, refactored the scripts to be Node-compatible (the Bun-only import.meta.dir had to become fileURLToPath(import.meta.url)), and published.
I ran the curls again. Still serving identical HTML on every route.
Discovery 2: A sentinel file is the only diagnostic that works when you can't see build logs
Lovable doesn't expose build logs to users. There's no shell access, no way to ssh into the build container, no dist/ directory listing. When something fails silently, you're flying blind.
I asked Lovable to do three things in the next change:
- Print the
dist/directory tree after the next build. - Print the first 30 lines of
dist/products/index.htmlif it existed. - Write a sentinel file to
dist/PRERENDER_RAN.txtwith a timestamp.
The first two would only help if Lovable shared the output back to me, which it could only do during the implementation session. The third worked permanently: if the sentinel file is reachable at the deployed URL, the plugin ran. If it isn't, the plugin didn't.
After the next publish, I ran:
curl https://creativecore.ai/PRERENDER_RAN.txt
Status 200. Body:
closeBundle invoked: 2026-05-13T04:52:55.434Z
cwd: /dev-server
dist entries: PRERENDER_RAN.txt, assets, book, clients, favicon.png, guides, index.html, og, placeholder.svg, products, robots.txt, sitemap.xml
The plugin ran. The directories existed. The host had the right files. But /products still returned the homepage HTML. Which meant the problem was somewhere new.
Sentinel files: free diagnostic, costs you one line of code, works on any host that doesn't show you logs. Use this pattern.
Discovery 3: Lovable's hosting jumps from "exact file match" straight to SPA fallback
I tested explicit extensions:
curl -s https://creativecore.ai/products.html | grep -oi "<title>[^<]*"
Returned: <title>Products, Templates, Playbooks & Tools | Creative Core AI. The right page.
curl -s https://creativecore.ai/products | grep -oi "<title>[^<]*"
Returned the homepage title.
So the prerendered file existed on the server. The host served it correctly when I asked for the full filename. But when I asked for the clean URL /products, the host skipped past /products.html and /products/index.html and fell straight to the SPA fallback (index.html).
Lovable confirmed this in their docs. SPA fallback is hardcoded at the infra level. No _redirects, no vercel.json, no _routes.json, no rewrites. You cannot configure it. There is no UI flag to flip.
The only path forward: change the canonical URLs of the entire site to use .html extensions explicitly. Sitemap entries, internal links, page-level canonical tags, OG URLs, the works. Then add a tiny React component that strips .html from the address bar after hydration so users see clean URLs in their browser even though crawlers fetch .html.
This is the working diagram now:
Google crawls https://creativecore.ai/guides/which-claude-plan.html
→ host serves dist/guides/which-claude-plan.html
→ real per-page title, OG, JSON-LD
→ Google indexes it correctly
User clicks an internal link to /products.html
→ host serves dist/products.html
→ React boots
→ HtmlSuffixStripper sees ".html" in pathname
→ calls history.replaceState to clean it up
→ user sees /products in the address bar
After this landed, I curled five routes plus a guide. Every title was unique. Every canonical pointed at its own .html URL. Every OG tag matched the page content. The plumbing was finally working.
What we built once the plumbing worked
With static-HTML-per-route actually shipping, the rest of the audit fixes were small. Most of them are just "add more metadata to the prerender script." Batched into two mega-prompts:
Mega-prompt round 1 combined five separate audit items:
- BreadcrumbList JSON-LD on every guide (three-level hierarchy: Home, Guides, Article)
dateModifiedfield on every Article schema (sourced from frontmatterupdated)- Full
/aboutpage with Person schema (founder bio, photo placeholder, links to GitHub, brand socials, CTA) - Wiring every guide's
authorfield to reference the Person@idon/about.html, so the entity graph resolves - Homepage
Organizationschema withsameAsarray (seven URLs covering Instagram, Facebook, LinkedIn, YouTube, X, TikTok, GitHub), plus a separateWebSiteschema referencing the Organization
Mega-prompt round 2 combined three:
- FAQPage schema, only for guides with four or more H2/H3 headings ending in "?". My estimate going in was 11 guides would qualify. The actual count was 1. The script was right to not force-fit (Google flags low-quality FAQ markup as spam).
- UTM auto-tagging on the share buttons. Click "copy link" on a guide and the URL pasted to your clipboard is
https://creativecore.ai/guides/.html?utm_source=share&utm_medium=copy-link&utm_campaign=. Channel attribution becomes automatic. /guideshub upgrade. Was a flat 12-card grid with a 12-word intro. Now has three anchored H2 clusters (Claude basics, AI marketing operations, Build with AI), proper intro copy, CollectionPage JSON-LD with an ItemList of all 12 guides (auto-built from the markdown directory, so new articles extend it for free).
Round 3 was IndexNow, standalone:
- Key file at
/public/.txt(Bing requires this for verification) - Auto-ping on build: scan guide frontmatter, find any whose
updateddate is today UTC, POST those URLs tohttps://api.indexnow.org/indexnow - Manual script for backfill or forced re-crawls:
bun run indexnow -- --all-guides
Verification ping returned HTTP 202 Accepted. Bing's index is what ChatGPT search and Copilot pull from. Getting new content into that index within minutes (vs. days) is the difference between "AI search cites you next week" and "AI search cites you next month."
Round 4 was llms.txt, the newer convention for AI crawlers:
- A plain-markdown file at
/llms.txtthat tells ChatGPT, Claude, Perplexity, and other AI surfaces what the site is, which pages matter, and what each page covers. - Auto-generated by the same prerender plugin: hardcoded "core pages" list, plus a "Guides" section built dynamically from
src/content/guides/*.mdfrontmatter (title, slug, meta description, sorted by published date). - Lives at
https://creativecore.ai/llms.txt. No effort to maintain. Adding a guide extends it automatically.
Discovery 4: prerendering metadata isn't enough, you have to prerender the body too
I thought the work was done after Google Search Console verified the site and Bing Webmaster imported the property. Then I ran one URL inspection in Bing and got this back:
Discovered but not crawled. URL cannot appear on Bing. The inspected URL is known to Bing but has some issues which are preventing indexation.
Bing knew about the URL (the IndexNow ping worked perfectly). But it refused to crawl. Vague error, no specifics. I ran one curl to figure out why:
curl -s https://creativecore.ai/guides/which-claude-plan.html | awk '<body>,</body>' | wc -c
Result: 45 bytes.
The entire <body> of every prerendered HTML page was just <div id="root"></div>. All my work had been head-only. Title, OG, canonical, Article schema, BreadcrumbList, FAQPage, all baked into <head>. The actual article text only appeared after React hydrated client-side.
Google's fine with this because Googlebot renders JavaScript by default. The GSC live test had said "Page can be indexed" earlier in the day for exactly this reason. But Bingbot, GPTBot, ClaudeBot, PerplexityBot, all the AI crawlers that pull from Bing's index, mostly don't render JS by default. They fetch the page, see rich head + empty body, and conclude there's no content worth indexing.
Quick check across the whole site showed every single page had a 45-byte body. The entire site was metadata-rich and content-blind for any non-JS crawler. Which is, depending on how you count, somewhere between half and most of the AI-search-relevant crawlers.
The fix: extend the prerender to render the markdown body into the static HTML body, not just metadata into the head. Same Vite plugin, additional pass. For guides, parse the markdown body (already loaded for FAQ extraction), render it to HTML, inject inside the <div id="root"> wrapper. For top-level routes, write static body content (intro paragraphs, H1, CTA) directly into the route config.
After the fix landed and shipped, the numbers:
/ → 1,167 bytes (was 45)
/about.html → 1,963 bytes (was 45)
/guides/which-claude-plan.html → 4,171 bytes (was 45)
/guides/what-is-claude-code.html → 8,678 bytes (was 45)
Real article text, real H1s, real article-meta lines, all crawlable without JavaScript. I re-pinged IndexNow for all 18 URLs (HTTP 200) so Bing would re-crawl with the new content.
The lesson here is the most uncomfortable one: I thought "prerendered HTML" meant a usable page. What I actually built was a prerendered head with an empty body. For Google, that's fine. For Bing and AI crawlers, that's a page with no content. The difference matters more than it should, and the only way to catch it is to fetch your own pages without JavaScript enabled and read what's actually there.
The kicker: Lovable's own SEO panel is blind to Lovable's own hosting
After everything was shipped and verified by curl, I opened Lovable's "Improve SEO" panel inside the editor. Here's what it told me, side by side with what was actually deployed:
| Lovable's panel says | Reality (curl against the live site) |
|---|---|
| Sitemap is missing. /sitemap.xml returns 404. | Returns 200 OK. 18 URLs. |
| Social previews are generic. OG URL is hardcoded to the homepage. Multiple routes share the same OG title and description. | Every .html route has its own og:title, og:url, og:image, og:description. Verified across 18 URLs. |
| Pages are missing schema. Sitewide ProfessionalService schema appears on all routes. Guides and product listings lack specific schema. | Every guide has Article + BreadcrumbList. One guide has FAQPage. Homepage has ProfessionalService + WebSite. /about has Person. /book has Service. /guides has CollectionPage + ItemList. |
| Page titles and descriptions are too long or duplicated. | Every title is unique. Lengths run 60 to 76 characters. The "duplicated" claim is false. |
The diagnosis is the same problem we worked around for Google: Lovable's scanner crawls clean URLs (/products, /guides/which-claude-plan), the hosting layer falls through to SPA fallback for those URLs, and the scanner sees identical homepage HTML on every route. It concludes the site is broken.
It cannot see the prerendered .html versions that the sitemap points to and that Google actually indexes. Lovable's panel is blind to Lovable's own hosting behavior.
Real crawlers (Googlebot, Bingbot, GPTBot, ClaudeBot, PerplexityBot) all start from the sitemap. The sitemap lists .html URLs. They get the right HTML. The only auditor looking at the wrong URLs is Lovable's panel itself.
This is concrete proof the workaround is doing its job. Any naive crawler that hits clean URLs without checking the sitemap will see the same broken view. That's the SPA fallback behavior we can't turn off. Google sees the right thing because Google crawls the sitemap. Lovable's scanner doesn't, which is a Lovable scanner bug, not a site bug.
Of Lovable's 8 flagged items, only 3 were legit:
- Google Search Console isn't connected.
- /llms.txt is missing (handled in Round 4 above).
- Accessibility contrast on some text (separate track, worth doing).
The other 5 were scanner false positives from looking at the wrong URLs.
What's still left
Nothing. As of 2026-05-13, every item from the original audit is shipped and verified. Google Search Console is verified, sitemap processed, 18 URLs discovered, BreadcrumbList JSON-LD detected as valid. Bing Webmaster is imported from GSC with the sitemap submitted manually after import. IndexNow auto-pings on every build whose updated field matches today. The 13-item audit is closed.
The lessons (the part you can apply to your own Lovable site)
1. Curl your deployed site, don't trust dev previews. Lovable's local dev and preview environments run your full npm build chain. Their production hosting runs only vite build. If your build does anything important after vite build, it isn't running in production. The only reliable way to know is curl https://your-site/some-page and read the raw HTML.
2. If you need anything beyond vite build, put it in a Vite plugin. The closeBundle hook fires after Vite writes dist/. That's where you put prerendering, sitemap generation, OG image generation, anything that touches the build output. Don't chain npm scripts. Don't add a post-build step in package.json. It won't run.
3. When your host doesn't show build logs, use a sentinel file. Write a tiny file (dist/RAN.txt or whatever) at the end of your build step with a timestamp and any debug info you need. Curl it after deploy. If it's 200, your build code ran. If it's 404, it didn't. Free, ten lines of code, works on any opaque hosting platform.
4. Lovable's hosting cannot resolve clean URLs to nested HTML files. If your dist/ has products/index.html, requesting /products will not serve it. The host jumps straight to SPA fallback. The workaround is to also emit flat files (dist/products.html) and update your sitemap, internal links, and canonical tags to use .html URLs explicitly. Then strip the extension client-side after hydration if you want clean URLs in the address bar. This is a one-time setup. New routes you add just need an entry in the prerender script.
5. New articles are now a non-event. The prerender script scans src/content/guides/*.md and emits a static HTML page for every markdown file. Add an article, set its updated field, publish, IndexNow auto-pings Bing. New top-level pages still require one line in scripts/prerender-routes.ts, but that's a one-time edit per route, not per piece of content.
6. If Lovable's hosting limitations keep blocking you, the migration path is short. Your codebase is just Vite + React. The prerender plugin is portable. Moving the same site to Vercel, Netlify, or Cloudflare Pages takes maybe an hour, and clean URLs resolve natively on all three. I'm not recommending migration today (the .html workaround is fine and Cloudflare sits in front of my domain already), but knowing the exit ramp exists makes the workaround easier to accept.
7. Don't trust the host's own SEO panel as ground truth. Lovable's "Improve SEO" panel told me the sitemap was missing, social previews were broken, and schema was absent, all of which were verifiably false against the live site. Their scanner crawls clean URLs and falls into the same SPA-fallback trap real crawlers would fall into if we hadn't done the .html workaround. Use Google Search Console, Bing Webmaster Tools, and the Rich Results Test as your real auditors. Use raw curl as your tiebreaker. Treat the host's panel as a hint, not a verdict.
8. Prerendered HTML means nothing if the body is empty. When I say "prerender every page," what I should have said the whole time was "prerender every page's head AND body." It's easy to lose track of which half you actually built. Googlebot renders JavaScript and will see hydrated content regardless, so Google never tells you the body is empty. Bing tells you, but the error is vague ("Discovered but not crawled, URL cannot appear on Bing"). The diagnostic is one line: curl -s <URL> | awk '<body>,</body>' | wc -c. If that's a number under, say, 500 bytes for a content page, your body is empty and AI crawlers are seeing nothing. Fix it before submitting URLs to IndexNow, or you'll have to resubmit after.
9. Your search engine's cache is older than you think. I opened GSC's URL Inspection on the homepage after everything was fixed and verified live. GSC said "URL is on Google" with a green checkmark, then showed me what was actually in their index: the original Lovable boilerplate. Title was just "Creative Core AI" with no value prop. Description was "AI-powered content strategy and brand consulting," which isn't even my business. The og:image was a Lovable preview environment URL that probably 404s now. TODO comments from the starter template were still in there. The body was empty. Every fix I'd shipped was invisible to Google's index because Google hadn't re-crawled since the original deploy. The lesson: after a major SEO overhaul, manually click Request Indexing in GSC and Bing on every important URL. Both consoles support it. Both have a daily quota (GSC is ~10-12 per property; Bing is more lenient). Don't wait for the search engines to re-discover your fixes on their natural schedule, you'll lose weeks of indexed-traffic potential to a stale cache.
The workflow now
When I add a new article:
- Drop a new
.mdfile insrc/content/guides/with frontmatter (title, slug, published, updated, og_image, meta_description, canonical_url). - If the date is today (UTC), the IndexNow auto-ping fires on the next build.
- Publish in Lovable.
- Done. Per-page HTML, JSON-LD, sitemap entry, IndexNow ping, OG image generation, all automatic.
When I update an existing article:
- Bump the
updated:field in the frontmatter to today's date. - Publish.
- IndexNow pings Bing with the updated URL. Google sees the new
dateModifiedin the JSON-LD and the newlastmodin the sitemap.
When I want to backfill or force a re-crawl:
bun run indexnow -- --all-guides
That's the whole runbook.
Appendix: the prompts, ready to use on your site
Two ways to use these:
Easy path: copy the meta-prompt below, paste it into Claude.ai (or Cursor, or any LLM chat), fill out the MY SITE block at the top, paste the prompts you want at the bottom, and Claude returns customized versions ready to paste into Lovable (or your build tool of choice). The meta-prompt does the find-replace for you across all of them in one shot.
Manual path: every prompt below has ${VARIABLES} you can find-replace in your editor. Slower, but no LLM round-trip needed.
The meta-prompt (paste this into Claude.ai or Cursor)
<details> <summary>Click to expand the meta-prompt</summary>Below is my site config and a set of generic SEO prompts. Customize each prompt by replacing every ${VARIABLE} with my actual values. Return the customized prompts one at a time, each in its own code block, in the same order I gave you. Do not paraphrase the prompts or change their structure. Only substitute the variables.
If a variable looks like JSON or a multi-line list, format it appropriately for the surrounding context (e.g., a JS array vs. inline text).
=== MY SITE ===
YOUR_DOMAIN: example.com
YOUR_SITE_URL: https://example.com
YOUR_BUSINESS_NAME: Example Business
YOUR_BUSINESS_TAGLINE: One-sentence value prop, under 100 characters
YOUR_BUSINESS_DESCRIPTION: Two to three sentence longer description for meta descriptions and Organization schema
YOUR_FOUNDER_NAME: First Last
YOUR_FOUNDER_TITLE: Founder
YOUR_FOUNDER_BIO_P1: First paragraph of your founder bio. Direct, in your voice. About 80-120 words.
YOUR_FOUNDER_BIO_P2: Second paragraph. Optional CTA-adjacent close. About 60-100 words.
YOUR_LOCATION_CITY: Houston
YOUR_LOCATION_REGION: TX
YOUR_LOCATION_COUNTRY: US
YOUR_BOOKING_URL: /book.html
YOUR_BOOKING_CTA: Book a free consult call
YOUR_GUIDES_DIR: src/content/guides
YOUR_BRAND_SOCIALS:
- https://www.instagram.com/example/
- https://www.linkedin.com/company/example/
(one URL per line, your brand's social accounts. Used in Organization.sameAs.)
YOUR_FOUNDER_PERSONAL_SOCIALS:
- https://github.com/yourhandle
(Personal-only accounts. Used in Person.sameAs. Brand socials do NOT go here.)
YOUR_KNOWS_ABOUT:
- Topic 1
- Topic 2
(3-7 topics your business is expert in. Used in Organization.knowsAbout.)
YOUR_OFFERS:
- { name: "Starter", priceRange: "$X-$Y" }
- { name: "Growth", priceRange: "$X-$Y/mo" }
(optional, your service tiers. Used in Organization.offers.)
=== PROMPTS TO CUSTOMIZE ===
(paste the prompts you want from the appendix here, one after another)
</details>
The 4 mega-prompts (what to actually paste into Lovable)
The 4 mega-prompts below are the consolidated set anyone using the updated /ccai-seo-audit + /ccai-seo-setup skills will get out of the box today. Each one batches multiple related fixes so you can ship them in one publish cycle.
When I originally ran the skills against creativecore.ai on 2026-05-12, the audit produced a 13-item fix list and the setup skill generated 13 individual prompts. During implementation I combined them into batches AND added several new prompts when each round of curl verification revealed a gap the original audit didn't catch (Lovable's hosted build ignoring npm script chains; SPA fallback for clean URLs; the empty-body trap that flagged in Bing; stale Google index after fixes shipped). After the dust settled, I hardened both skills to detect and fix those gaps automatically. So the mega-prompts below now include what I had to discover the hard way.
Each mega-prompt's intro notes which of the original 13 it consolidates and which gap discoveries it folds in. If you want the per-item granular version, the skills are open source:
ccai-seo-audit(the diagnostic): github.com/cory-dot/ccai-seo-audit. Crawls your live site and outputs a prioritized fix list.ccai-seo-setup(the implementation): github.com/cory-dot/ccai-seo-setup. Reads the audit and writes the per-fix prompts. The full Lovable case study lives intemplates/hosting/lovable.md; Vite + React patterns intemplates/stacks/vite-react.md.ccai-skills-pack(the full library): github.com/cory-dot/ccai-skills-pack. 34 Creative Core AI skills in one monorepo, with one-command installers.
Running the audit + setup skills directly is the recommended path. The mega-prompts below are here for readers who want to see the end state without setting up Claude Code.
These assume you're on Lovable with the default Vite + React + TypeScript stack. Other hosting platforms (Vercel, Netlify, Cloudflare Pages) work the same way except they can skip the .html URL workaround in Mega-Prompt 1.
Consolidates original prompts: 01 (soft-404 fix), 02 (per-page metadata via SSG), 03 (canonical tags).
Folds in gap discoveries from this session: Vite plugin closeBundle pattern (replaces the npm script chain in the original prompt 02, since Lovable only executes vite build); Node-compatible scripts (Bun-only import.meta.dir doesn't work in Lovable's hosted build); sentinel file diagnostic (since Lovable doesn't expose build logs); body content prerender (since head-only prerender leaves Bing flagging "Discovered but not crawled"); .html URL workaround (since Lovable's hosting layer falls through to SPA fallback for clean URLs); internal link rewriting in markdown bodies (so Bing's deduplication doesn't pick the wrong canonical).
This is the largest mega-prompt. Run it first. Everything else depends on it.
The site is currently a Vite + React SPA where every URL serves the same dist/index.html with empty body content. All per-page metadata only appears after JavaScript hydrates client-side. Crawlers that don't render JS (Bingbot by default, social platform unfurlers, GPTBot, ClaudeBot, PerplexityBot) see only the homepage version and won't index the rest. Fix this with build-time prerendering, body content rendering, and a hosting-layer workaround. Then add a sentinel file so future deploys are verifiable.
Important context for implementation:
- Lovable's hosted build executes "vite build" only, NOT the package.json build script chain. Anything that needs to run after vite build must live inside a Vite plugin's closeBundle hook.
- All scripts must be Node-compatible. import.meta.dir (Bun-only) does not work. Use fileURLToPath(import.meta.url).
- Lovable's hosting does not resolve clean URLs to nested index.html or sibling .html files. It falls through to SPA fallback for /products, /guides/, etc. The workaround is to emit .html URLs explicitly and strip the suffix client-side after hydration.
=== 1. Soft-404 fix ===
In src/pages/NotFound.tsx, inject a noindex meta tag and a prerender-status-code: 404 hint into the document head on mount. Clean up on unmount. Rebrand the page UI to match site design with links to / and to your main content hub. (SPA hosting can't return a real 404 status; the noindex meta is what Google explicitly recommends as the equivalent.)
useEffect(() => {
const prev = document.title;
document.title = "Page not found · ${YOUR_BUSINESS_NAME}";
const robots = document.createElement("meta");
robots.name = "robots"; robots.content = "noindex, follow";
document.head.appendChild(robots);
const status = document.createElement("meta");
status.setAttribute("name", "prerender-status-code"); status.content = "404";
document.head.appendChild(status);
return () => { document.title = prev; robots.remove(); status.remove(); };
}, [location.pathname]);
=== 2. Vite plugin for build-time prerender ===
Create scripts/prerender-routes.ts, scripts/prerender-guides.ts, scripts/generate-sitemap.ts. All use fileURLToPath(import.meta.url) for Node-compatible path resolution.
prerender-routes.ts: walks a static list of top-level routes (/, /about, /products, /clients, /book, /guides) with metadata + JSON-LD + static body HTML per route. Emits dist/<route>.html AND dist/<route>/index.html for each (dual layout, portable).
prerender-guides.ts: walks ${YOUR_GUIDES_DIR}/*.md, parses frontmatter, renders the markdown body to HTML, emits dist/guides/<slug>.html AND dist/guides/<slug>/index.html for each guide. Each file gets full head metadata (title, description, og:*, canonical, Article JSON-LD) plus rendered body content injected into the index.html template's <div id="root">.
generate-sitemap.ts: builds dist/sitemap.xml from the route list + the guides directory. Each entry uses the .html URL form. Sorts guides by published/updated date descending. Appends "Sitemap: ${YOUR_SITE_URL}/sitemap.xml" line to dist/robots.txt.
Wire all three into a Vite plugin in vite.config.ts:
import type { Plugin } from "vite";
function prerenderPlugin(): Plugin {
return {
name: "${YOUR_DOMAIN}-prerender",
apply: "build",
async closeBundle() {
const { prerenderRoutes } = await import("./scripts/prerender-routes");
const { prerenderGuides } = await import("./scripts/prerender-guides");
const { generateSitemap } = await import("./scripts/generate-sitemap");
prerenderRoutes();
prerenderGuides();
generateSitemap();
},
};
}
export default defineConfig(({ mode }) => ({
plugins: [
react(),
mode !== "development" && prerenderPlugin(),
].filter(Boolean),
...
}));
=== 3. Body content prerender (non-negotiable) ===
In prerender-guides.ts, after rendering the markdown body to HTML, inject it into the <div id="root"> alongside the head metadata. Build an article scaffold:
<div id="root">
<article class="guide-article">
<header class="article-header">
<h1 class="article-title">{title}</h1>
<p class="article-meta">
By {author} · Published {formatDate(published)}{updated && updated !== published ? ` · Updated ${formatDate(updated)}` : ''}
</p>
</header>
<div class="article-body" dangerouslySetInnerHTML={{ __html: renderedMarkdownHtml }} />
</article>
</div>
For top-level routes (no markdown source), hand-write body content blocks in the route config (at minimum: H1, primary value-prop paragraph, primary CTA link).
After the markdown body is rendered to HTML, run a final pass that rewrites any internal link href ending in a clean URL to the .html form:
https://${YOUR_DOMAIN}/guides/<slug> → ${YOUR_SITE_URL}/guides/<slug>.html
/guides/<slug> → /guides/<slug>.html
/products, /clients, /book, /about, /guides → /products.html, etc.
Skip anchor links (#section), external URLs, already-.html links, mailto:, tel:.
=== 4. .html URL convention site-wide ===
Update generate-sitemap.ts to emit .html URLs in <loc> entries (except the homepage, which is just /).
Update all <link rel="canonical"> and <meta property="og:url"> tags in the prerender to use .html URLs for non-homepage routes.
Update internal <a href> and navigate() calls in the React app to use .html URLs.
Add HtmlSuffixStripper inside <BrowserRouter> in App.tsx so users see clean URLs in the address bar after hydration:
const HtmlSuffixStripper = () => {
const { pathname, search, hash } = useLocation();
const navigate = useNavigate();
useEffect(() => {
if (pathname.endsWith(".html") && pathname !== "/index.html") {
navigate(pathname.replace(/\.html$/, "") + search + hash, { replace: true });
}
}, [pathname, search, hash, navigate]);
return null;
};
=== 5. Sentinel file diagnostic ===
At the end of closeBundle, write dist/PRERENDER_RAN.txt with a timestamp and the dist directory listing. This is the only reliable way to verify the plugin ran on Lovable's hosted build (since build logs aren't exposed).
import { writeFileSync, readdirSync } from "node:fs";
const entries = readdirSync(DIST).join(", ");
writeFileSync(
join(DIST, "PRERENDER_RAN.txt"),
`closeBundle invoked: ${new Date().toISOString()}\ncwd: ${process.cwd()}\ndist entries: ${entries}\n`,
"utf8"
);
=== Verify after publish ===
curl ${YOUR_SITE_URL}/PRERENDER_RAN.txt # → 200, timestamp + dist listing
curl -s ${YOUR_SITE_URL}/products.html | grep -oi "<title>[^<]*" # → unique per-page title
curl -s ${YOUR_SITE_URL}/products.html | awk '/<body>/,/<\/body>/' | wc -c # → >500 bytes
curl -s ${YOUR_SITE_URL}/ | grep -c "application/ld+json" # → ≥2 (Org + WebSite)
</details>
<details>
<summary><strong>Mega-Prompt 2: Schema wiring + About page + Author entity graph</strong></summary>
Consolidates original prompts: 04 (Article JSON-LD), 05 (BreadcrumbList), 06 (Person schema on /about), 07 (Updated date pattern), 13 (Homepage sameAs + WebSite schema).
Notes: This is exactly the mega-prompt I sent to Lovable as "Round 1" after the foundation was in place. All five items touch metadata baked into the prerender scripts, so they go together.
Five SEO improvements. All five touch metadata baked into the prerender scripts. Apply as one change.
Context: All site URLs use .html convention (HtmlSuffixStripper handles the address bar). All JSON-LD URLs use .html where applicable. @id values are logical identifiers, also using .html paths.
=== 1. Finish Article JSON-LD on every guide ===
Add a BreadcrumbList block on every guide:
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "${YOUR_SITE_URL}/" },
{ "@type": "ListItem", "position": 2, "name": "Guides", "item": "${YOUR_SITE_URL}/guides.html" },
{ "@type": "ListItem", "position": 3, "name": <article.title>, "item": <canonical URL> }
]
}
Add "dateModified" to the Article JSON-LD, pulling from frontmatter: dateModified: article.updated || article.published.
=== 2. Create /about page with Person schema ===
Create src/pages/About.tsx with: hero (photo + name + title + location), bio paragraphs (use ${YOUR_FOUNDER_BIO_P1} and ${YOUR_FOUNDER_BIO_P2}), "What I work on" block (from ${YOUR_KNOWS_ABOUT}), proof-of-work links (${YOUR_FOUNDER_PERSONAL_SOCIALS} + ${YOUR_BRAND_SOCIALS}), primary CTA at bottom (${YOUR_BOOKING_CTA} → ${YOUR_BOOKING_URL}).
Add /about route to React Router AND /about.html (HtmlSuffixStripper handles direct landings).
Add /about to scripts/prerender-routes.ts so it emits dist/about.html with full body content.
Add /about.html to sitemap generation.
Add "About" to the main nav.
Generate OG image at /public/og/about.png (1200×630, dark background, photo left, name+title right).
Person JSON-LD on /about (baked into prerendered HTML):
{
"@context": "https://schema.org",
"@type": "Person",
"@id": "${YOUR_SITE_URL}/about.html#${YOUR_FOUNDER_NAME_SLUG}",
"name": "${YOUR_FOUNDER_NAME}",
"url": "${YOUR_SITE_URL}/about.html",
"image": "${YOUR_SITE_URL}/${YOUR_FOUNDER_FIRSTNAME_LOWER}.jpg",
"jobTitle": "${YOUR_FOUNDER_TITLE}",
"worksFor": {
"@type": "Organization",
"@id": "${YOUR_SITE_URL}/#organization",
"name": "${YOUR_BUSINESS_NAME}",
"url": "${YOUR_SITE_URL}/"
},
"address": {
"@type": "PostalAddress",
"addressLocality": "${YOUR_LOCATION_CITY}",
"addressRegion": "${YOUR_LOCATION_REGION}",
"addressCountry": "${YOUR_LOCATION_COUNTRY}"
},
"sameAs": [${YOUR_FOUNDER_PERSONAL_SOCIALS}]
}
Person.sameAs ONLY includes personal accounts (e.g., GitHub). Brand socials go in Organization.sameAs (section 5 below).
=== 3. Wire Article author to Person @id ===
In the Article JSON-LD on every guide, replace:
"author": { "@type": "Person", "name": "${YOUR_FOUNDER_NAME}" }
with:
"author": { "@id": "${YOUR_SITE_URL}/about.html#${YOUR_FOUNDER_NAME_SLUG}" }
Don't redefine the Person inside the Article block. The @id references the Person entity on /about.
=== 4. Three-signal Updated date consistency ===
(a) Add `updated:` field to all guide markdown frontmatters. Initialize to the same value as `published`:
---
title: ...
slug: ...
published: YYYY-MM-DD
updated: YYYY-MM-DD
---
(b) In the guide article rendering (both runtime React and prerender body output), under the H1, show BOTH dates only if updated differs from published.
(c) In generate-sitemap.ts, update <lastmod> for each /guides/<slug>.html entry to use article.updated || article.published from frontmatter (not file mtime, not today's date).
(d) Article JSON-LD dateModified already covered in section 1.
=== 5. Homepage Organization + WebSite schema ===
Add two JSON-LD blocks on the homepage:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"@id": "${YOUR_SITE_URL}/#organization",
"name": "${YOUR_BUSINESS_NAME}",
"url": "${YOUR_SITE_URL}/",
"description": "${YOUR_BUSINESS_DESCRIPTION}",
"areaServed": "United States",
"address": { ... ${YOUR_LOCATION_*} ... },
"founder": [
{
"@type": "Person",
"@id": "${YOUR_SITE_URL}/about.html#${YOUR_FOUNDER_NAME_SLUG}",
"name": "${YOUR_FOUNDER_NAME}",
"jobTitle": "${YOUR_FOUNDER_TITLE}"
}
],
"knowsAbout": [${YOUR_KNOWS_ABOUT}],
"sameAs": [${YOUR_BRAND_SOCIALS}],
"offers": [${YOUR_OFFERS}]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "${YOUR_SITE_URL}/#website",
"url": "${YOUR_SITE_URL}/",
"name": "${YOUR_BUSINESS_NAME}",
"publisher": { "@id": "${YOUR_SITE_URL}/#organization" }
}
</script>
Verify:
curl -s ${YOUR_SITE_URL}/guides/<slug>.html | grep -c "application/ld+json" # → 2 (Article + BreadcrumbList)
curl -sI ${YOUR_SITE_URL}/about.html # → HTTP 200
curl -s ${YOUR_SITE_URL}/about.html | grep '"@type":"Person"' # → match
curl -s ${YOUR_SITE_URL}/ | grep -A 2 sameAs # → brand social URLs
curl -s ${YOUR_SITE_URL}/ | grep -c "application/ld+json" # → 2 (Org + WebSite)
</details>
<details>
<summary><strong>Mega-Prompt 3: FAQ schema + UTM share tagging + /guides hub upgrade</strong></summary>
Consolidates original prompts: 08 (FAQPage schema), 10 (UTM auto-tagging on share buttons), 11 (/guides index enhancement).
Notes: This is "Round 2" mega-prompt. All three touch in-app rendering: FAQ extraction from article markdown, UTM tagging on share buttons, and the /guides page React component.
Three improvements. All URLs use .html convention.
=== 1. FAQPage schema on qualifying guides ===
For every guide whose body contains 4+ H2 or H3 headings ending in "?", inject a third JSON-LD block (FAQPage) alongside the existing Article + BreadcrumbList blocks.
In scripts/prerender-guides.ts, add an extractFAQs helper that walks the parsed markdown AST. For each H2/H3 ending in "?", capture as `question`; the immediately-following paragraph (skip blank/heading nodes) is the `answer`. Skip pairs where the answer paragraph contains a link to ${YOUR_BOOKING_URL} or other CTA URLs (those are closers, not informational FAQs). Strip markdown formatting from the answer, truncate to 300 chars.
If 4+ qualify, emit:
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqs.map(qa => ({
"@type": "Question",
"name": qa.question,
"acceptedAnswer": { "@type": "Answer", "text": qa.answer }
}))
}
If fewer than 4 qualify, emit nothing. Don't force-fit FAQ markup; Google flags low-quality FAQ schema as spam.
=== 2. UTM auto-tagging on share buttons ===
On every shareable page (guides, etc.), wire share buttons to append UTMs before copying or opening the share dialog:
function copyShareLink() {
const u = new URL(window.location.href);
u.search = '';
if (!u.pathname.endsWith('.html')) {
u.pathname = u.pathname.replace(/\/?$/, '.html');
}
u.searchParams.set('utm_source', 'share');
u.searchParams.set('utm_medium', 'copy-link');
const slug = u.pathname.replace(/\.html$/, '').split('/').filter(Boolean).pop() || 'home';
u.searchParams.set('utm_campaign', slug);
navigator.clipboard.writeText(u.toString())
.then(() => showToast('Link copied'))
.catch(() => showToast('Could not copy, copy URL manually'));
}
Apply same UTM pattern to dedicated share buttons (X, LinkedIn, Facebook, Email), each with its own utm_medium value.
Replace any window.prompt fallback with a non-modal sonner / react-hot-toast notification. Dark surface matching site visual, 3s auto-dismiss.
Configure analytics (flock.js or whatever you use) to capture utm_*, gclid, fbclid, ref query params on every pageview.
=== 3. /guides hub upgrade ===
In src/pages/Guides.tsx, replace the flat grid with:
- 2-3 paragraph intro (what these guides are, who they're for, 2 "start here" links to top guides, primary CTA to ${YOUR_BOOKING_URL})
- 3+ H2-grouped clusters of articles, each with anchorable id. H2 + muted-color blurb + filtered ArticleGrid per cluster.
Update the /guides route entry in scripts/prerender-routes.ts:
- Title: "Free Guides · ${YOUR_BUSINESS_TAGLINE} | ${YOUR_BUSINESS_NAME}"
- Description: brief summary of what the hub covers
- OG image: /og/guides.png (generate 1200×630 if missing, match per-article OG style)
- CollectionPage JSON-LD with mainEntity ItemList auto-built from the guide directory:
{
"@context": "https://schema.org",
"@type": "CollectionPage",
"@id": "${YOUR_SITE_URL}/guides.html#collection",
"name": "${YOUR_BUSINESS_NAME} Guides",
"description": "...",
"url": "${YOUR_SITE_URL}/guides.html",
"publisher": { "@id": "${YOUR_SITE_URL}/#organization" },
"mainEntity": {
"@type": "ItemList",
"itemListElement": guides.map((g, i) => ({
"@type": "ListItem",
"position": i + 1,
"url": `${YOUR_SITE_URL}/guides/${g.slug}.html`,
"name": g.title
}))
}
}
The ItemList auto-extends as you add new guide markdown files.
Verify:
curl -s ${YOUR_SITE_URL}/guides/<faq-qualifying-slug>.html | grep -c "application/ld+json" # → 3
curl -s ${YOUR_SITE_URL}/guides/<faq-qualifying-slug>.html | grep '"@type":"FAQPage"' # → match
curl -s ${YOUR_SITE_URL}/guides.html | grep -oE '"position":[0-9]+' # → all positions
</details>
<details>
<summary><strong>Mega-Prompt 4: Indexation infrastructure (IndexNow + llms.txt + search console meta tags)</strong></summary>
Consolidates original prompts: 09 (IndexNow integration), 12 (Bing Webmaster verification, partially, the meta tag part).
Folds in gap discoveries: llms.txt auto-generation (not in original 13, newer convention for AI crawlers); GSC + Bing verification meta tag placeholders (originally manual steps, now wired into the prerender so they survive future builds); file-based fallback for IndexNow key (since Lovable's runtime secrets don't reliably plumb into Vite's build env).
Three indexation improvements. All apply to the existing prerender plugin.
=== 1. IndexNow with auto-ping on build ===
Generate a 32-char hex key (crypto.randomBytes(16).toString("hex") or openssl rand -hex 16). Same value in all three places below.
Place /public/<KEY>.txt with the key string as its only content (Bing fetches this to verify ownership).
Store the key in two places:
- As env var INDEXNOW_KEY (no VITE_ prefix, must stay server-side)
- As fallback file .indexnow-key in repo root (Lovable's hosted build doesn't reliably plumb env vars into Vite, so the file fallback is the reliable source)
Build scripts read env first, then fall back to the file:
function getIndexNowKey() {
return process.env.INDEXNOW_KEY ?? (existsSync(".indexnow-key") ? readFileSync(".indexnow-key", "utf8").trim() : undefined);
}
Add auto-ping to prerenderPlugin's closeBundle hook. It walks ${YOUR_GUIDES_DIR}/*.md, collects guides where (updated || published) == today UTC, and POSTs to https://api.indexnow.org/indexnow:
{
"host": "${YOUR_DOMAIN}",
"key": "<key>",
"keyLocation": "${YOUR_SITE_URL}/<key>.txt",
"urlList": [<.html URLs>]
}
Log result. Never fail the build on IndexNow errors.
Add a manual backfill script at scripts/indexnow-ping.ts. Accepts URLs as CLI args, or --all-guides, or --all-routes. Add package.json alias: "indexnow": "bun run scripts/indexnow-ping.ts".
=== 2. llms.txt for AI crawlers ===
Create scripts/generate-llms-txt.ts. Wire into prerenderPlugin's closeBundle, after generateSitemap.
Output format (plain markdown, file lives at /llms.txt):
# ${YOUR_BUSINESS_NAME}
> ${YOUR_BUSINESS_DESCRIPTION}
## Core pages
- [Home](${YOUR_SITE_URL}/): one-sentence description
- [About ${YOUR_FOUNDER_NAME}](${YOUR_SITE_URL}/about.html): one-sentence description
- [Book a consult](${YOUR_BOOKING_URL}): one-sentence description
- (one line per top-level route)
## Guides
${SHORT_INTRO_FOR_GUIDES_SECTION}
- [Article title](${YOUR_SITE_URL}/guides/<slug>.html): meta_description from frontmatter
- (one line per guide, sorted by published date descending, auto-built from ${YOUR_GUIDES_DIR})
## Optional
- [Sitemap](${YOUR_SITE_URL}/sitemap.xml): full URL list with last-modified dates
Brief site description in the blockquote should be 2-3 sentences, slightly fuller than og:description.
=== 3. Google Search Console + Bing Webmaster verification meta tags ===
Add two meta tags to the homepage <head>. Place them in the / route entry's static tags in scripts/prerender-routes.ts (so they end up baked into dist/index.html, not hydrated client-side; verifiers fetch the raw HTML):
<meta name="google-site-verification" content="PLACEHOLDER_REPLACE_ME" />
<meta name="msvalidate.01" content="PLACEHOLDER_REPLACE_ME" />
These sit alongside the existing verification files (googleXXXXXXX.html and BingSiteAuth.xml), as extra paths in case file-based verification fails.
Verify:
curl ${YOUR_SITE_URL}/<KEY>.txt # → 200 OK, key string
curl -X POST "https://api.indexnow.org/indexnow" \
-H "Content-Type: application/json" \
-d '{"host":"${YOUR_DOMAIN}","key":"<key>","keyLocation":"${YOUR_SITE_URL}/<key>.txt","urlList":["${YOUR_SITE_URL}/guides/<some-slug>.html"]}'
# → HTTP 200 or 202
curl -s ${YOUR_SITE_URL}/llms.txt | head -30
# → starts with "# ${YOUR_BUSINESS_NAME}", followed by description, Core pages list, Guides list.
curl -sI ${YOUR_SITE_URL}/llms.txt | grep -i content-type
# → text/plain
curl -s ${YOUR_SITE_URL}/ | grep -i "google-site-verification"
# → <meta name="google-site-verification" content="PLACEHOLDER_REPLACE_ME" />
# (Cory will replace this with the real value once GSC provides it.)
</details>
One-liner verification commands
After implementing any of the above, run these from a terminal to confirm the state of the live site:
# === Site-wide structural checks ===
curl -s ${YOUR_SITE_URL}/PRERENDER_RAN.txt # → 200 OK
curl -s ${YOUR_SITE_URL}/ | grep -i "<title>" # → "${YOUR_BUSINESS_NAME} — ${YOUR_BUSINESS_TAGLINE}"
curl -s ${YOUR_SITE_URL}/ | grep -c "application/ld+json" # → ≥2 (Org + WebSite)
curl -s ${YOUR_SITE_URL}/ | grep -i "google-site-verification" # → present
# === Per-page uniqueness ===
curl -s ${YOUR_SITE_URL}/products.html | grep -oi "<title>[^<]*" # → "Products, Templates, Playbooks & Tools | ${YOUR_BUSINESS_NAME}"
curl -s ${YOUR_SITE_URL}/guides.html | grep -oi "<title>[^<]*" # → "Free Guides · ${YOUR_BUSINESS_TAGLINE} | ${YOUR_BUSINESS_NAME}"
curl -s ${YOUR_SITE_URL}/about.html | grep -oi "<title>[^<]*" # → "About ${YOUR_FOUNDER_NAME} | ${YOUR_BUSINESS_NAME}"
curl -s ${YOUR_SITE_URL}/book.html | grep -oi "<title>[^<]*" # → "${YOUR_BOOKING_CTA} | ${YOUR_BUSINESS_NAME}"
curl -s ${YOUR_SITE_URL}/guides/<any-slug>.html | grep -oi "<title>[^<]*"
# === Body content check (the Bing trap) ===
curl -s ${YOUR_SITE_URL}/guides/<any-slug>.html | awk '/<body/,/<\/body>/' | wc -c
# Expected: much larger than 45 bytes. For a typical guide, several KB.
# If this is 45 bytes, your body is empty. See "Discovery 4" above.
# === Sitemap ===
curl -s ${YOUR_SITE_URL}/sitemap.xml | grep "<url>" | wc -l # → count of URLs
# === llms.txt ===
curl -s ${YOUR_SITE_URL}/llms.txt | head -5 # → "# ${YOUR_BUSINESS_NAME}"
# === IndexNow key ===
curl -s ${YOUR_SITE_URL}/<KEY>.txt # → key string
These commands assume the ${VARIABLES} above have been replaced with your actual values. If you installed the ccai-seo-audit and ccai-seo-setup skills, the customized versions of these verification commands are generated for you automatically in seo-setup/verify.sh.