Skip to content

Latest commit

 

History

History
611 lines (421 loc) · 39.4 KB

File metadata and controls

611 lines (421 loc) · 39.4 KB

CLAUDE.md — All Phase Construction USA Site

Read this file first at the start of every session. It is the canonical architecture brief for allphase-construction-v2. It supersedes and consolidates the earlier CLAUDE.md and CLAUDE-NOTES.md. Last refreshed: 2026-04-20 (post-PR-15).


1. What this repo is

Production site for All Phase Construction USA — a dual-licensed roofing contractor (CCC-1331464 / CGC-1526236) headquartered in Deerfield Beach, FL, serving Broward County and Palm Beach County. Live at https://allphaseconstructionfl.com.

  • Repo: chrisp-png/allphase-construction-v2 (GitHub)
  • Owner / primary operator: Chris Porosky (chris.p@allphasesfl.com, backup cporosky@gmail.com)
  • Git config: user.email = chris.p@allphasesfl.com, user.name = Chris Porosky
  • Deploy: GitHub main → Netlify auto-build, ~2–3 min per deploy

This is the only canonical codebase. An abandoned Bolt.new duplicate ("All Phase Construction Website (duplicated)") never deployed — ignore it. Chris occasionally edits this repo via Bolt directly on main, so always git pull --rebase before pushing.


2. Stack

  • Vite 5.4 + React 18.3 + TypeScript 5.5 (strict)
  • Tailwind 3.4 (JIT) + @tailwindcss/typography
  • React Router 7 (client-side routing, SPA)
  • react-helmet-asyncruntime meta management (schema/meta for humans + hydrated view only)
  • Supabase JS 2.57 — blog content (read-only in production)
  • Puppeteer 22 — used by scripts/prerender-static.mjs during build
  • Sharp 0.34 — image optimization (local npm run build only; not in Netlify command)
  • Deployed on Netlify with edge functions (Deno runtime)

3. Build pipeline

Netlify build command (the one that actually ships)

npx vite build && node scripts/prerender-static.mjs && node scripts/submit-indexnow.mjs

Defined in netlify.toml. Publish dir: dist/. Node 20.

Local npm run build (fuller chain)

build-blog-content → optimize-images → vite build → prerender → generate-sitemap → generate-html-sitemap

Netlify skips build-blog-content, optimize-images, generate-sitemap, generate-html-sitemap. Anything that must gate a deploy has to run inside prerender-static.mjs or be a Netlify plugin. public/blog-content.json and public/sitemap.xml are committed to the repo so Netlify serves pre-generated artifacts.

Important sandbox limitation

The Claude sandbox cannot npm install or run vite build (proxy blocks npm + supabase + the live site + api.netlify.com). Only node --check scripts/prerender-static.mjs works locally for syntax checks. Verify all changes by:

  1. node --check for JS/MJS,
  2. npx tsc --noEmit -p tsconfig.app.json for TypeScript (when deps are available),
  3. pushing to GitHub and watching the live site through Claude in Chrome (bypasses the proxy).

api.github.com and raw.githubusercontent.com are allowlisted — you can inspect remote repo state from the sandbox.


4. Routing & render paths

Two coexisting render paths for every URL:

Path A — Prerendered static HTML (dist/<route>/index.html). Generated by scripts/prerender-static.mjs. This is what Googlebot and Bingbot see on first request.

Path B — Client-side React SPA. After first paint, src/main.tsx bootstraps the Router and hydrates. Subsequent navigation is client-side.

The two paths must agree: titles, canonicals, schema, and visible content should match post-hydration or Google will flag soft 404s and broken enhancements.

Route table at a glance (current: 322 prerendered index.html files in dist/)

  • / — home
  • /services, /services/:slug — service hub + leaves (roof-replacement, roof-repair, roof-inspection, etc.)
  • /roof-replacement, /roof-repair, /roof-inspection — dedicated silo roots
  • /roof-repair/:city (28 city variants) — repair-intent geo pages
  • /roof-inspection/:city (7 city variants) — inspection-intent geo pages
  • /roof-cost-calculator — cost estimator tool
  • /locations — state hub
  • /locations/broward-county, /locations/palm-beach-county — county hubs
  • /locations/:slug — ~30 city MoneyPages + ~14 thin cities via GenericLocationTemplate = 44 total cities
  • /locations/:city/:landmark — landmark cluster pages (Boca Raton: Mizner Park / Town Center / FAU, etc.)
  • /locations/service-areas — global service-area index
  • /best-roofers-* — 16 listicle pages (best-roofers-in-boca-raton, etc.) — built from src/data/bestRoofersData.ts
  • /blog — blog index
  • /blog/:slug — individual posts (mix of prerendered + SPA-fallback for Supabase-only posts)
  • /sitemap (HTML sitemap) + /sitemap.xml (XML sitemap)
  • /about, /contact, /reviews, /gallery, /financing, /warranties, /privacy, /terms, /accessibility, /faq, /areas-we-serve

DynamicCityRouter.tsx is the runtime dispatcher for /locations/:slug:

  1. ~30 hardcoded MoneyPage components in src/pages/locations/*MoneyPage.tsx for high-revenue cities (boca-raton, boynton-beach, broward-county, coconut-creek, coral-springs, davie, deerfield-beach, delray-beach, fort-lauderdale, hallandale-beach, hollywood, lauderdale-by-the-sea, lauderdale-lakes, lauderhill, margate, miramar, north-lauderdale, palm-beach, palm-beach-county, palm-beach-gardens, parkland, pembroke-pines, plantation, pompano-beach, southwest-ranches, sunrise, tamarac, wellington, west-palm-beach, wilton-manors). Each is a custom React page.
  2. src/pages/templates/GenericLocationTemplate.tsx fallback for ~14 thin Wave-C cities. Uses src/data/cities.json, not locations.ts. Mounts <StickyConversionBar />.
  3. src/pages/locations/DeerfieldBeachCityPage.tsx — dedicated HQ page with its own faqItems array + runtime FAQPage schema (see §9 for the duplicate-FAQ fix).

WARNINGsrc/pages/locations/CityMoneyPage.tsx is an orphan. Not imported anywhere. Do not edit thinking it controls /locations — the real templates are the hardcoded MoneyPages + GenericLocationTemplate + DeerfieldBeachCityPage.


5. Prerender script (scripts/prerender-static.mjs)

This is the single most important file in the repo. ~6,300 lines. It is a regex-and-string build-time HTML generator that runs after vite build. For each route in the site's route table it:

  1. Loads the Vite-built dist/index.html as a template via loadProductionTemplate().
  2. Substitutes route-specific <title>, meta description, canonical URL, Open Graph tags, robots.
  3. Injects JSON-LD schema immediately before </head>.
  4. Injects SEO-only static HTML into a hidden #seo-static block inside <body> so Google sees content without executing React.
  5. Writes the final HTML to both dist/<route>/index.html and public/<route>/index.html.

Client-side React still hydrates and takes over after first paint. The prerendered HTML exists purely so Google and Bing see rendered content.

Major sections inside prerender-static.mjs

Line (approx) Purpose
25–40 Reads locations.ts as TEXT, parses via regex (see §12 foot-gun)
~1599 generateDeerfieldBeachSchema() — HQ-specific RoofingContractor schema
~1753 baseOrgSchema — default RoofingContractor injected when a page has no RoofingContractor of its own
~1810 schemaToInject logic — page-specific RoofingContractor REPLACES baseOrgSchema
~2248 CITY_PAGE_SCHEMAS — per-slug hand-crafted schema arrays (RoofingContractor + optional FAQPage + BreadcrumbList)
~2680–2820 LOCATIONS.forEach loop — injects trust strip, updated-stamp, FAQ, map, testimonials, county backlink
~2804 Wave-C FAQ backfill — appends FAQPage if missing; gated by CITIES_WITH_OWN_FAQ (PR-14)
(later) LANDMARKS.forEach — landmark pages with FAQPage + BreadcrumbList
(later) COUNTY_HUBS.forEach — county hub schema (RoofingContractor + BreadcrumbList)
(later) Blog loop — reads sitemap.xml for blog slugs, generates blog HTML
(later) BEST_ROOFERS_DATA iteration — 16 listicle pages
(later) SEO_TITLES iteration — 74 titled route entries

Generator / injector helpers

  • buildLocationSeo(location) — title/desc/canonical/schemaJsonLd (duplicated from src/lib/locationSeo.ts; see §7)
  • buildLocationFaqs(city, location) — 6-question replacement-focused FAQ array
  • buildFaqSchema(faqs) — FAQPage JSON-LD
  • buildFaqHtml(city, faqs) — visible <details> HTML
  • buildMapHtml(city) — keyless Google Maps iframe
  • buildTestimonialsHtml(city, location) — 2 city-specific testimonials, deterministically rotated
  • generateDeerfieldBeachHQContent() — HQ hero block
  • createHTMLTemplate(...) — final template assembly

6. JSON-LD / schema policy

All schema that must be indexed is generated in scripts/prerender-static.mjs. JSON-LD added via react-helmet-async or useEffect runs only post-hydration and is not reliably seen by Google's initial crawl.

Three schema layers

  1. baseOrgSchema (~L1753) — default RoofingContractor for pages without a more specific RoofingContractor. Carries aggregateRating (source of the ⭐ rich snippet). Does NOT carry an inline review array (removed 2026-04-06 to fix GSC "multiple aggregate ratings" errors on best-roofers pages).
  2. Page-specific schema — when the page schema is itself @type: RoofingContractor, it REPLACES baseOrgSchema (see schemaToInject around L1810). Page-specific RoofingContractors must carry their own aggregateRating or they lose the star snippet.
  3. Wave-C backfill (~L2804) — scans the location-page schema array; if no FAQPage, appends one built from buildLocationFaqs(city, location). Skipped when the slug is in CITIES_WITH_OWN_FAQ (currently ['deerfield-beach']) — see §9.

Schema by page type

  • / — RoofingContractor + Organization + WebSite + LocalBusiness
  • /locations/:slug — RoofingContractor (page-specific or base) + BreadcrumbList + FAQPage (from Wave-C or CITY_PAGE_SCHEMAS or runtime for Deerfield)
  • /locations/:city/:landmark — FAQPage + BreadcrumbList
  • /locations/broward-county, /palm-beach-county — RoofingContractor + BreadcrumbList
  • /roof-repair/:city, /roof-inspection/:city — Service + BreadcrumbList + FAQPage
  • /best-roofers-* — base RoofingContractor + BreadcrumbList (no review array — killed 2026-04-06)
  • /blog/:slug — BlogPosting + BreadcrumbList

7. Meta tags (titles, descriptions, canonicals)

Source of truth: src/lib/locationSeo.tsbuildLocationSeo(location) returns {title, description, canonical, robots, ogTitle, ogDescription, ogUrl, schemaJsonLd, schemaObject}.

A duplicate of buildLocationSeo lives inside scripts/prerender-static.mjs because the prerender runs in Node and can't import TS directly. If you change one, change both. They must stay in sync.

Other meta sources:

  • src/config/seoTitles.tsgenerateSEOMetadata() — runtime React meta (118 SEO_TITLES entries: 74 named routes + 44 SPA-shell-victim slugs added in PR-25 — see §17)
  • src/components/NuclearMetadata.tsxSINGLE OWNER for runtime <title> and critical meta. Introduced to prevent react-helmet-async double-renders. When present on a page, no other component should render <Helmet> with <title>.
  • Per-Location titleOverride, descriptionOverride, canonicalOverride on records in src/data/locations.ts

56 files use react-helmet-async. Most MoneyPages render <Helmet> for description/OG. Titles should be owned by NuclearMetadata where present.

The isFallback contract (PR-25)

When generateSEOMetadata(path) falls through to the generic fallback at the bottom of seoTitles.ts, it MUST set isFallback: true on the returned SEOMetadata object. NuclearMetadata.tsx reads that flag and prefers window.__PRERENDERED_TITLE__ / window.__PRERENDERED_DESC__ over the fallback values. This is what protects prerendered-but-unrouted URLs (the 44 SPA-shell-victim slugs — see §17) from having their correct title stomped during JS-rendered crawls (Googlebot 2026 renders JS; Screaming Frog with rendering=JavaScript renders JS).

If you add a new fallback path inside generateSEOMetadata, set isFallback: true on it. Failing to do so re-introduces the title-stomp regression PR-25 fixed.


8. Data sources (single source of truth)

File Purpose
src/data/locations.ts SOT for all 44 /locations/:slug cities: county, hvhz flag, neighborhoods, zips, landmarks, overrides
src/data/cities.json Parallel SOT consumed by GenericLocationTemplate runtime only
src/data/cityCoordinates.ts Lat/lng per city
src/data/landmarks.ts Landmark cluster pages (PR #2)
src/data/bestRoofersData.ts Content for 16 best-roofers-* listicle pages
src/config/seoTitles.ts 74 route → title/description entries
public/blog-content.json Static blog HTML, generated from /blog/*.md
public/sitemap.xml Committed sitemap (also regenerated locally via scripts/generate-sitemap.mjs)

The prerender script reads these same files (as JSON or via regex-parser for .ts) so Node-side and browser-side stay consistent. Do NOT introduce hardcoded BROWARD_CITIES / PALM_BEACH_CITIES arrays — location.county is canonical (3-way drift caused by those arrays was fixed in April 2026).


9. FAQ system — read before touching

Two FAQ mechanisms coexist:

  1. Prerender-side Wave-C backfill (in prerender-static.mjs) — appends a generic 6-question FAQPage + visible <details> HTML to every /locations/:slug page that doesn't already have one.
  2. Runtime component-side FAQPage — a few pages (currently just DeerfieldBeachCityPage.tsx) use useEffect to append a FAQPage JSON-LD AND render a visible FAQ section in JSX.

If both fire on the same slug, Google sees two FAQPage blocks on one URL → GSC "FAQ: invalid items" warnings. PR-14 fixed this by introducing:

const CITIES_WITH_OWN_FAQ = new Set(['deerfield-beach']);

Near the Wave-C backfill (L2804):

const existingArr = Array.isArray(hubSchema) ? hubSchema : (hubSchema ? [hubSchema] : []);
const alreadyHasFaq = existingArr.some(s => s && s['@type'] === 'FAQPage');
const faqs = buildLocationFaqs(city, location);
const cityOwnsFaq = CITIES_WITH_OWN_FAQ.has(slug);
if (!alreadyHasFaq && !cityOwnsFaq) {
  hubSchema = [...existingArr, buildFaqSchema(faqs)];
}
const faqHtmlBlock = cityOwnsFaq ? '' : buildFaqHtml(city, faqs);

Rule: If you add a dedicated React component that emits its own FAQPage schema for a slug, add the slug to CITIES_WITH_OWN_FAQ. Otherwise the prerender will double-inject.

FAQ rich-text pattern (PR-15)

Inside dedicated React components (e.g. DeerfieldBeachCityPage.tsx), FAQ answers with internal <Link>s must be split into two fields to avoid rendering literal <Link>...</Link> as text:

const faqItems: Array<{
  question: string;
  answer: string;              // plain text for schema
  answerNodes?: ReactNode;     // optional JSX with <Link> for render
}> = [...];

// Schema:
"text": item.answer

// JSX:
{faq.answerNodes ?? faq.answer}

Do NOT put <Link>...</Link> as raw string inside answer — React will render it literally.

Also: in any <Link> target, prefer the final canonical path. /calculator 301-redirects to /roof-cost-calculator — link straight to /roof-cost-calculator to avoid the hop.

FAQ rich-result reality check

Google killed FAQ rich results for non-government/health sites in August 2023. Our FAQPage schema is still useful for:

  • Entity understanding / topic relevance
  • GSC Enhancements validation (clean "0 invalid items")
  • Future if Google revives the treatment

But it will NOT surface as FAQ rich snippets in SERPs. Don't over-invest time chasing FAQ rich snippets — the wins are elsewhere (RoofingContractor ratings, BreadcrumbList, LocalBusiness).


10. Netlify config (netlify.toml + public/_redirects)

Key settings

  • publish = "dist"
  • pretty_urls = falsedo NOT flip this. Enabling it caused full-site redirect loops (commits c51bbb9d, b7a937c0).
  • NODE_VERSION = "20"

Edge functions (Deno, netlify/edge-functions/)

  • strip-slash.ts — runs on /*/, /wp-content/*, /wp-admin/*, /wp-includes/*, /wp-json/*, /wp-login.php, /xmlrpc.php, /feed. Strips trailing slashes, handles dead routes, returns 410 for WordPress artifacts.
  • blog-redirects.ts — runs on /blog/*. Handles dead/legacy blog URLs (301s + 410s via BLOG_REDIRECTS map) and calls context.next() for live slugs. If Netlify returns 404 (blog slug exists in Supabase but not prerendered), rewrites to /index.html so the SPA handles it.

Edge functions run before static file resolution and before _redirects / netlify.toml rules. This is why blog redirects must live in the edge function — dist/blog/ is a real directory and static-file resolution would otherwise short-circuit _redirects.

Redirect priority (ascending processing order)

  1. Edge functions
  2. public/_redirects
  3. netlify.toml [[redirects]]
  4. Static files in dist/

force = true (301! in _redirects) overrides static files but not directory resolution. SPA fallback /* → /index.html 200 has force = false and must be the last redirect rule.

Current active redirects (inventory)

In netlify.toml:

  • /wind-mitigation-inspection → /roof-inspection/ 301 force
  • /roofing-services/residential-roofing → /roofing-services/ 301 force
  • /locations/deerfield-beach/service-area/:city/top-5-roofer(*) → /locations/:city 301 force
  • /locations/deerfield-beach/service-area/:city(*) → /locations/:city/ 301
  • GSC soft-404 fixes (March 2026): /roof-repair/north-lauderdale → /roof-repair, /locations/westlake → /locations/west-palm-beach, /locations/palm-beach → /locations/west-palm-beach, /locations/lauderdale-by-the-sea → /locations/fort-lauderdale, /roof-inspection/westlake → /roof-inspection, /roof-inspection/lake-park → /roof-inspection
  • Thanksgiving blog → /410.html status 410 force
  • Dead blog posts → redirected to live equivalents (how-long-will-it-take, tapered-insulation, clay-roof-tiles, tar-seal, services-provided, common-myths, association-budgets)
  • /boca-raton → /locations/boca-raton 301 force
  • /blog/* → /index.html 200 (SPA fallback after edge function)
  • /* → /index.html 200 (final SPA fallback)

Headers (caching)

  • /*X-Robots-Tag = index, follow
  • /assets/*, *.jpg, *.png, *.webp, *.css, *.jsCache-Control: public, max-age=31536000, immutable
  • /robots.txttext/plain, max-age 3600
  • /sitemap.xmlapplication/xml, max-age 3600
  • /favicon.svgimage/svg+xml, max-age 86400

11. Blog content system

Supabase (primary)

  • URL: https://vsjebxljdhomgmqbqgdi.supabase.co
  • Anon key in src/lib/supabase.ts
  • RLS: read-only for anon (SELECT only). Cannot INSERT/UPDATE/DELETE from the site.
  • Dashboard was created via Bolt under a different account. chrisp-png does NOT have Supabase dashboard access.
  • Table: blog_posts (id, slug, title, content, excerpt, published, published_date, author, categories, tags, featured_image, meta_title, meta_description, faqs, related_post_ids, view_count, …)
  • Sandbox proxy blocks direct Supabase calls; verify via Claude in Chrome.

Static fallback chain in src/pages/BlogPostPage.tsx (~L195–290)

  1. Fetch post from Supabase
  2. If Supabase content is placeholder ("See blog/xxx.md" or < 500 chars), fetch /blog-content.json and splice in the content
  3. If Supabase returns null, check /blog-content.json for the slug + build a synthetic post using staticPostMeta map (currently 3 entries: family-owned-business, how-to-hire-a-roofer, screen-enclosure-cost)

public/blog-content.json

  • Generated by scripts/build-blog-content.mjs from /blog/*.md
  • ~16 entries (slug → HTML)
  • Committed to the repo
  • Copied to dist/ via the custom manualPublicCopyPlugin (since publicDir: false)
  • Netlify does NOT regenerate it during build — commit the generated file

One special slug mapping: how-to-hire-a-roofer-in-south-florida-what-to-look-for-and-what-to-avoid.md → slug how-to-hire-a-roofer-in-south-florida

Runtime trim (PR-11)

BlogPostPage.tsx runtime trims blog title to 60 chars and meta description to 160 chars before rendering Helmet tags — don't undo this.


12. Critical foot-guns

🔴 prerender-static.mjs reads locations.ts as TEXT, not as a TS import

const locationsRaw = fs.readFileSync(locationsDataPath, 'utf-8');
const locationsMatch = locationsRaw.match(/export const locations: Location\[\] = \[([\s\S]*?)\];/);
const locationsArrayText = '[' + locationsMatch[1] + ']';
const locationsJson = locationsArrayText
  .replace(/\/\/[^\n]*/g, '')
  .replace(/\/\*[\s\S]*?\*\//g, '')
  .replace(/(\w+):/g, '"$1":')   // ← THE FOOT-GUN
  .replace(/,(\s*[}\]])/g, '$1');
const LOCATIONS = JSON.parse(locationsJson);

The (\w+): regex has no string-awareness. Any word: inside a string value in locations.ts gets mangled and breaks JSON.parse, killing the Netlify build with SyntaxError: Expected ',' or '}'.

Never write inside string values in src/data/locations.ts:

  • Free inspection: (754) 227-5605 — broke the build on 2026-04-06 AND 2026-06-25 (recurring; this is now also documented inline at the top of src/data/locations.ts). Use Free inspection (754)… or a hyphen.
  • Note: …, Tip: …, URL: …, Hours: …
  • Time strings like 9:00 AM
  • URLs may or may not trip it depending on surrounding chars — test before adding.

Same rule applies to src/data/landmarks.ts. Use double-quoted strings only. Single quotes won't parse.

Future cleanup: convert the text-regex parser to dynamic TS import or flip locations.ts to a .json. Until then, this rule is the rule.

🟡 Netlify is NOT reporting build status to GitHub

api.github.com/repos/.../commits/{sha}/check-runs returns total_count: 0 and /status returns state: pending with empty statuses for every commit. Netlify's GitHub webhook triggers builds but doesn't post checks back. To verify a deploy:

  1. Open Netlify → Deploys, OR
  2. Hit the live URL via Claude in Chrome and check content.

🟡 publicDir: false in vite.config.ts

Vite's automatic public directory copying is disabled. A custom manualPublicCopyPlugin selectively copies files. When adding a new static file to public/, it must be added to the seoFiles array:

const seoFiles = [
  'robots.txt',
  'sitemap.xml',
  'sitemap.html',
  '_headers',
  '_redirects',
  'llms.txt',
  'llms-full.txt',
  'blog-content.json',
];

Otherwise the file will not ship to Netlify.

🟡 Prerendered blog directory confuses Netlify redirects

scripts/prerender-static.mjs creates dist/blog/{slug}/index.html for each prerendered blog slug. This forces Netlify into "directory mode" for /blog/*, which means _redirects / netlify.toml redirects don't fire for paths inside that directory. That's why blog redirects MUST live in netlify/edge-functions/blog-redirects.ts.

🟡 Schema from React components does not survive prerender

Client-side useEffect that appends <script type="application/ld+json"> to document.head only runs after hydration. Google's initial crawl does not see it. Always add indexable schema in prerender-static.mjs. The one exception is pages with a dedicated React component that ALSO has matching prerender schema (e.g. DeerfieldBeachCityPage) — in that case the prerender path must skip its generic injection (see CITIES_WITH_OWN_FAQ).

🟡 RoofingContractor schemas replace the base

If a page-specific schema is @type: RoofingContractor, it entirely replaces baseOrgSchema. That means the page loses aggregateRating unless the page-specific schema includes it explicitly. Always include aggregateRating on page-specific RoofingContractor schemas.

🟡 Do not re-add inline review arrays

They re-trigger GSC "multiple aggregate ratings" errors on best-roofers pages. Only aggregateRating belongs inside a RoofingContractor.

🟡 pretty_urls = false must stay false

Flipping it has caused full-site redirect loops twice. Don't touch without extreme care.

🟡 Adding a new prerendered slug requires FOUR coordinated edits

A new slug typed only into prerender-static.mjs writes a static dist/<slug>/index.html but is INVISIBLE to React. To survive JS-rendered crawls (Googlebot 2026, Screaming Frog with rendering=JavaScript), the slug also needs:

  1. An entry in scripts/seo-titles.json under staticTitles (title, description, canonical). The prerender's getSEOMetadata() reads from this JSON file and HARD-FAILS the build with MISSING METADATA FOR ROUTE if the slug isn't registered. This is the loudest of the four edits — skip it and the Netlify deploy aborts in ~25 seconds before the dist file is even written. Hit in PR-48 (2026-04-30): cost three deploy roundtrips because the contributor only added the entry to src/config/seoTitles.ts (which is for the React runtime, not the prerender).
  2. Either an explicit <Route> in App.tsx or a registered fallback that StaticContentPage handles via __PRERENDERED_HTML__ (the catchall already does this — check that the prerender writes <div id="seo-static">…</div> into the HTML or the catchall has nothing to render).
  3. An entry in SEO_TITLES in src/config/seoTitles.ts mirroring the prerender data (title, description, canonical). Without this, NuclearMetadata calls generateSEOMetadata, the lookup falls through to the fallback, and isFallback's prerender-preserving guard kicks in — but only as a safety net. NOTE: this is a SEPARATE file from scripts/seo-titles.json. The TS file is for runtime React; the JSON file is for the build-time prerender. Both need a matching entry.
  4. The slug added to SPA_SHELL_VICTIM_SLUGS in prerender-static.mjs (PR-24 verifier) so the build fails loudly if you accidentally remove the prerender for it.

The PR-24 build-time verifier catches missing dist files. It does NOT catch a missing seoTitles.ts entry — that regression is invisible at build time and only shows up in JS-rendered crawls. The scripts/seo-titles.json guard at line ~2609 catches missing JSON entries at build time. Always do all four edits.

🟡 Hidden off-screen navigation

There's a position: absolute; left: -10000px; nav somewhere on the site. Known cloaking risk, intentionally left for now. Do not touch in unrelated PRs.


13. Deploy checklist (schema-affecting PRs)

  1. node --check scripts/prerender-static.mjs — syntax sanity check.
  2. If React changed: npx tsc --noEmit -p tsconfig.app.json (when deps are available locally).
  3. Commit + push to a feature branch; open PR via gh pr create.
  4. Wait 2–3 minutes after merge to main for Netlify deploy.
  5. Verify live via Claude in Chrome — navigate to the changed page, view source, confirm the <script type="application/ld+json"> block is correct.
  6. Paste the LIVE URL into https://search.google.com/test/rich-results and confirm expected schema types are detected.
  7. In GSC, hit Request Indexing on changed URLs.
  8. After 24 hrs, check GSC Enhancements (FAQ, Breadcrumbs, Review snippets) for regressions.
  9. If a batch of URLs changed, submit the updated sitemap in GSC and verify IndexNow submitted (scripts/submit-indexnow.mjs output appears in Netlify build log).

14. Directives for content edits

These apply to every content change on the site:

  1. NO insurance language in new copy. South Florida east coast — no hail, no recent hurricanes. Never write "we work with your insurance carrier." Avoid: insurance, claim, carrier, deductible, hail, hurricane damage. Existing grandfathered copy (Boca Raton + Delray Beach meta descriptions) stays unless explicitly told to remove.
  2. Location pages are REPLACEMENT pages. Title/H1/meta should signal roof replacement, reroofing, new roof — not generic "roofing services." Repair and inspection intents have their own silos.
  3. HVHZ jurisdictional accuracy. Only Broward + Miami-Dade are legally HVHZ. Palm Beach cities are "voluntarily built to HVHZ spec." Don't claim Palm Beach properties are HVHZ-required.
  4. Don't introduce new problems. The site was working. Verify before pushing.

15. SEO history that matters

  • April 2026 pivot — flipped all /locations/* titles from "Roofing Services in X" to "Roof Replacement in X" (commit 55a949de).
  • 2026-04-06 — removed inline review arrays from all schemas to fix GSC "multiple aggregate ratings" errors.
  • 2026-04-06 — Wave-C trust strip + FAQ + map + testimonials + updated-stamp injected on every /locations/:city page via LOCATIONS.forEach.
  • 2026-04-13 — Boca Raton landmark pages added (Mizner Park, Town Center, FAU) for geo-relevance toward palm-beach-county-roofing-contractor.
  • 2026-04-13 — county-hub backlink block added to every prerendered city page.
  • PR-11 (merged) — runtime trim of blog titles to 60 chars and descriptions to 160 chars.
  • PR-12 (merged)topRooferMatch handler fixed broken slug titles on top-5-roofer service-area pages.
  • PR-13 (merged, 2026-04-18) — trailing-slash canonical mismatches on /locations/service-areas and /sitemap fixed.
  • PR-14 (merged, 2026-04-19, commit b4bd32ed) — fixed duplicate FAQPage on /locations/deerfield-beach (CITIES_WITH_OWN_FAQ set).
  • PR-15 (merged, 2026-04-20, commit cba2d26e) — FAQ answers render proper JSX <Link> components instead of literal <Link> text. answer/answerNodes split pattern. /calculator/roof-cost-calculator.
  • PR-24 (merged, 2026-04-26, commit 52aaaeec) — Build-time SPA-shell-victim verifier in scripts/prerender-static.mjs. Runs at the end of generateStaticFiles(), asserts each of 44 audit-flagged slugs has a dist/<slug>/index.html with a non-Vite-shell <title>. Throws to fail the Netlify build if any are missing or stale. Override: env SPA_SHELL_VERIFIER=warn. Regression guard for the prerender pipeline. See §17.
  • PR-25 (merged, 2026-04-26, commit b687a906) — Fixed the actual root cause of the audit's "44 duplicate titles" finding. NuclearMetadata was overwriting correct prerendered titles with the generic fallback during JS-rendered crawls (Googlebot 2026, SF rendering=JavaScript) because none of the 44 slugs were in SEO_TITLES. Two halves: (a) added 44 explicit SEO_TITLES entries mirroring prerender data, (b) added isFallback?: boolean to SEOMetadata so NuclearMetadata can prefer window.__PRERENDERED_TITLE__ when the SEO lookup falls through. Architectural fix protects future regressions. See §7 and §17.

Leadsnap baseline (April 2026)

palm beach county roofing contractor: avg rank 6.26, Top 3% 45.56%, market share 57.04% (2nd place). Target by end of April: avg rank ≤ 3.0, Top 3% ≥ 60%.

GSC known issues (as of 2026-04-20)

  • Soft 404 — validation cycle active after redirect fixes
  • Page with redirect — many intentional legacy 301s
  • Server error (5xx) — validation active
  • FAQ invalid items on /locations/deerfield-beach — fixed in PR-14, awaiting re-crawl validation
  • Review snippets appearing on home page — correct; no FAQ/Breadcrumbs on home is also correct (home is root, no parent for breadcrumb; Google killed FAQ rich results for non-government/health sites in 2023)

Remaining findings from 2026-04-20 Screaming Frog crawl

Resolved:

  • 45 geo-page duplicate titles — fixed in PR-24 (build-time verifier) + PR-25 (NuclearMetadata isFallback guard + 44 SEO_TITLES entries). 4/26/2026 SF crawl confirmed the fallback-stomp was the actual cause; build-time dist files were already correct. See §17.

Still open (not yet authorized):

  • 37 "Post Not Found" blog pages
  • 6 titles > 60 chars
  • 3 descriptions > 160 chars

16. Key files reference

File Purpose
netlify.toml Build command, edge function registration, redirects, headers
public/_redirects Secondary redirect rules (processed before netlify.toml)
vite.config.ts publicDir: false + manualPublicCopyPlugin
scripts/prerender-static.mjs The SEO lever — generates static HTML. Reads locations.ts as TEXT (see foot-gun §12). Now also runs verifySpaShellArtifacts(SPA_SHELL_VICTIM_SLUGS) at the end of generateStaticFiles() (PR-24, see §17).
scripts/submit-indexnow.mjs IndexNow submission post-deploy
scripts/build-blog-content.mjs Generates public/blog-content.json from /blog/*.md
scripts/generate-sitemap.mjs Generates public/sitemap.xml (committed)
src/data/locations.ts SOT for all 44 /locations/:slug cities
src/data/cities.json Parallel SOT consumed by GenericLocationTemplate at runtime
src/data/landmarks.ts Landmark cluster pages
src/data/bestRoofersData.ts best-roofers-* content (16 pages)
src/data/cityCoordinates.ts City lat/lng
src/lib/locationSeo.ts buildLocationSeo() — duplicated in prerender script; keep in sync
src/lib/supabase.ts Supabase client config
src/config/seoTitles.ts 118 route → title/desc entries for runtime React (74 named + 44 SPA-shell-victim slugs added in PR-25). Fallback return sets isFallback: true so NuclearMetadata can preserve prerendered titles — see §7 and §17.
src/components/NuclearMetadata.tsx SINGLE OWNER for runtime <title>. Reads window.__PRERENDERED_TITLE__ / __PRERENDERED_DESC__ and prefers them when isFallback === true on the SEOMetadata returned by generateSEOMetadata (PR-25).
src/components/StickyConversionBar.tsx Desktop top bar + mobile bottom CTA; on all MoneyPages + GenericLocationTemplate
src/pages/DynamicCityRouter.tsx Dispatches /locations/:slug at runtime
src/pages/templates/GenericLocationTemplate.tsx Fallback for ~14 thin cities
src/pages/locations/DeerfieldBeachCityPage.tsx HQ city page; owns its own FAQ schema + JSX render (see §9)
src/pages/locations/CityMoneyPage.tsx ⚠️ ORPHAN — not imported
src/pages/locations/*MoneyPage.tsx ~30 hardcoded city MoneyPages
src/pages/BlogPostPage.tsx Blog render + Supabase → static fallback chain
netlify/edge-functions/strip-slash.ts Trailing slash + dead route + WordPress 410
netlify/edge-functions/blog-redirects.ts Blog URL redirects + SPA fallback for missing blog pages
public/blog-content.json Static blog HTML (generated, committed)
public/sitemap.xml XML sitemap (committed)
blog/*.md Blog post markdown source files
CLAUDE.md THIS FILE — read first
CLAUDE-NOTES.md Prior notes; largely subsumed by this file, kept for history

17. Getting help

When in doubt, stop and ask. The prerender script is long and has historical crust; the wrong change can silently break every page at once. Prefer small, surgical PRs with node --check + Rich Results validation over broad sweeps.


17. SPA-shell-victim subsystem (PR-24 + PR-25)

This is the most subtle architectural pattern in the repo and the one most likely to regress silently. Read this section before touching any of: prerender-static.mjs, App.tsx, seoTitles.ts, NuclearMetadata.tsx, StaticContentPage.tsx, or main.tsx.

What "SPA-shell victim" means

A URL whose React Router never matches an explicit <Route> falls into App.tsx's catchall path="*" (line 328). The catchall renders <StaticContentPage /> if window.__PRERENDERED_HTML__ is set (captured by main.tsx from the page's <div id="seo-static"> block before React wipes the DOM), else <NotFoundPage />.

For URLs with a dist/<slug>/index.html written by the prerender but no explicit React Route, the static HTML loads correctly on first paint. After hydration, three things race:

  1. main.tsx snapshots __PRERENDERED_TITLE__ and __PRERENDERED_HTML__ from the prerendered DOM.
  2. React Router runs, hits the catchall, mounts StaticContentPage.
  3. NuclearMetadata (mounted in the layout) runs its useEffect and calls generateSEOMetadata(path).

The bug PR-25 fixed

If the slug is not in SEO_TITLES and does not match any other branch in generateSEOMetadata (location, blog, landmark, best-roofers, top-roofer), the function returns the generic fallback. NuclearMetadata previously wrote that fallback to document.title, stomping the correct prerendered title.

This was invisible to non-JS-rendering crawlers but visible to:

  • Googlebot 2026 (renders JS before indexing)
  • Screaming Frog with rendering=JavaScript
  • Any AI crawler that executes JS (some do)

The 4/26/2026 SF crawl flagged 44 such URLs all sharing the fallback title Roofing Contractor | Broward & Palm Beach | All Phase USA.

How the fix works

SEO_TITLES (PR-25 part A): 44 explicit entries added mirroring the prerender data. Lookup now hits, NuclearMetadata writes the correct title.

isFallback flag (PR-25 part B): The fallback return path in generateSEOMetadata now sets isFallback: true. NuclearMetadata reads the flag and uses window.__PRERENDERED_TITLE__ / __PRERENDERED_DESC__ instead of the fallback. This is the architectural safety net — any future prerendered-but-unrouted slug that gets added without a corresponding SEO_TITLES entry will still serve the correct prerendered title to JS-rendering crawlers.

Verifier (PR-24): Runs at end of generateStaticFiles(). Reads each slug in SPA_SHELL_VICTIM_SLUGS, asserts the dist file exists and has a non-Vite-shell title. Throws to fail the build if any fail. This catches build-time regressions (missing prerender output) but does NOT catch missing seoTitles.ts entries — that's what isFallback is for.

When you add a new prerendered slug

Always do all three of:

  1. Add the slug entry to a data array in prerender-static.mjs with slug, title, description, h1, intro, sections (or whatever the surrounding array's shape is). This writes the static HTML.
  2. Add the slug to SEO_TITLES in src/config/seoTitles.ts with title and description that mirror the prerender data exactly. This fixes the JS-render path.
  3. Add the slug to SPA_SHELL_VICTIM_SLUGS in prerender-static.mjs so the verifier protects it. This makes future build-time regressions loud.

If you skip step 2, the isFallback guard catches you — but you've added latency to every navigation to that page (the prerender title is captured, then re-applied) and [NUCLEAR METADATA] Applied: will log usedPrerenderTitle: true. Watch the console for that — it indicates a missing SEO_TITLES entry.

If you skip step 3, the verifier won't catch a regression on that page, only the others.

Files involved

  • scripts/prerender-static.mjs — writes static HTML, runs verifier (lines ~2080-2230 declare SPA_SHELL_VICTIM_SLUGS + verifySpaShellArtifacts()).
  • src/config/seoTitles.ts — declares isFallback?: boolean, sets it on fallback return.
  • src/components/NuclearMetadata.tsx — reads isFallback + window.__PRERENDERED_*.
  • src/main.tsx — captures __PRERENDERED_TITLE__ / __PRERENDERED_HTML__ / __PRERENDERED_DESC__ before React mounts.
  • src/pages/StaticContentPage.tsx — re-injects __PRERENDERED_HTML__ for the catchall.
  • src/App.tsx — catchall route at line ~328.

Override

If you need to ship around the verifier without removing the check, set Netlify env:

SPA_SHELL_VERIFIER=warn

Default is fail. Use sparingly.