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).
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.
- 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-async— runtime 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.mjsduring build - Sharp 0.34 — image optimization (local
npm run buildonly; not in Netlify command) - Deployed on Netlify with edge functions (Deno runtime)
npx vite build && node scripts/prerender-static.mjs && node scripts/submit-indexnow.mjs
Defined in netlify.toml. Publish dir: dist/. Node 20.
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.
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:
node --checkfor JS/MJS,npx tsc --noEmit -p tsconfig.app.jsonfor TypeScript (when deps are available),- 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.
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.
/— 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 fromsrc/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:
- ~30 hardcoded MoneyPage components in
src/pages/locations/*MoneyPage.tsxfor 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. src/pages/templates/GenericLocationTemplate.tsxfallback for ~14 thin Wave-C cities. Usessrc/data/cities.json, notlocations.ts. Mounts<StickyConversionBar />.src/pages/locations/DeerfieldBeachCityPage.tsx— dedicated HQ page with its ownfaqItemsarray + runtime FAQPage schema (see §9 for the duplicate-FAQ fix).
WARNING — src/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.
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:
- Loads the Vite-built
dist/index.htmlas a template vialoadProductionTemplate(). - Substitutes route-specific
<title>, meta description, canonical URL, Open Graph tags, robots. - Injects JSON-LD schema immediately before
</head>. - Injects SEO-only static HTML into a hidden
#seo-staticblock inside<body>so Google sees content without executing React. - Writes the final HTML to both
dist/<route>/index.htmlandpublic/<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.
| 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 |
buildLocationSeo(location)— title/desc/canonical/schemaJsonLd (duplicated fromsrc/lib/locationSeo.ts; see §7)buildLocationFaqs(city, location)— 6-question replacement-focused FAQ arraybuildFaqSchema(faqs)— FAQPage JSON-LDbuildFaqHtml(city, faqs)— visible<details>HTMLbuildMapHtml(city)— keyless Google Maps iframebuildTestimonialsHtml(city, location)— 2 city-specific testimonials, deterministically rotatedgenerateDeerfieldBeachHQContent()— HQ hero blockcreateHTMLTemplate(...)— final template assembly
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.
baseOrgSchema(~L1753) — defaultRoofingContractorfor pages without a more specific RoofingContractor. CarriesaggregateRating(source of the ⭐ rich snippet). Does NOT carry an inlinereviewarray (removed 2026-04-06 to fix GSC "multiple aggregate ratings" errors on best-roofers pages).- Page-specific schema — when the page schema is itself
@type: RoofingContractor, it REPLACESbaseOrgSchema(seeschemaToInjectaround L1810). Page-specific RoofingContractors must carry their ownaggregateRatingor they lose the star snippet. - Wave-C backfill (~L2804) — scans the location-page schema array; if no
FAQPage, appends one built frombuildLocationFaqs(city, location). Skipped when the slug is inCITIES_WITH_OWN_FAQ(currently['deerfield-beach']) — see §9.
/— 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
Source of truth: src/lib/locationSeo.ts → buildLocationSeo(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.ts—generateSEOMetadata()— runtime React meta (118 SEO_TITLES entries: 74 named routes + 44 SPA-shell-victim slugs added in PR-25 — see §17)src/components/NuclearMetadata.tsx— SINGLE OWNER for runtime<title>and critical meta. Introduced to preventreact-helmet-asyncdouble-renders. When present on a page, no other component should render<Helmet>with<title>.- Per-Location
titleOverride,descriptionOverride,canonicalOverrideon records insrc/data/locations.ts
56 files use react-helmet-async. Most MoneyPages render <Helmet> for description/OG. Titles should be owned by NuclearMetadata where present.
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.
| 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).
Two FAQ mechanisms coexist:
- Prerender-side Wave-C backfill (in
prerender-static.mjs) — appends a generic 6-question FAQPage + visible<details>HTML to every/locations/:slugpage that doesn't already have one. - Runtime component-side FAQPage — a few pages (currently just
DeerfieldBeachCityPage.tsx) useuseEffectto 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.
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.
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).
publish = "dist"pretty_urls = false— do NOT flip this. Enabling it caused full-site redirect loops (commits c51bbb9d, b7a937c0).NODE_VERSION = "20"
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 viaBLOG_REDIRECTSmap) and callscontext.next()for live slugs. If Netlify returns 404 (blog slug exists in Supabase but not prerendered), rewrites to/index.htmlso 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.
- Edge functions
public/_redirectsnetlify.toml[[redirects]]- 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.
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/:city301 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.htmlstatus 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-raton301 force/blog/* → /index.html 200(SPA fallback after edge function)/* → /index.html 200(final SPA fallback)
/*→X-Robots-Tag = index, follow/assets/*,*.jpg,*.png,*.webp,*.css,*.js→Cache-Control: public, max-age=31536000, immutable/robots.txt→text/plain, max-age 3600/sitemap.xml→application/xml, max-age 3600/favicon.svg→image/svg+xml, max-age 86400
- 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-pngdoes 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.
- Fetch post from Supabase
- If Supabase content is placeholder (
"See blog/xxx.md"or< 500 chars), fetch/blog-content.jsonand splice in the content - If Supabase returns
null, check/blog-content.jsonfor the slug + build a synthetic post usingstaticPostMetamap (currently 3 entries: family-owned-business, how-to-hire-a-roofer, screen-enclosure-cost)
- Generated by
scripts/build-blog-content.mjsfrom/blog/*.md - ~16 entries (slug → HTML)
- Committed to the repo
- Copied to
dist/via the custommanualPublicCopyPlugin(sincepublicDir: 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
BlogPostPage.tsx runtime trims blog title to 60 chars and meta description to 160 chars before rendering Helmet tags — don't undo this.
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 ofsrc/data/locations.ts). UseFree 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.
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:
- Open Netlify → Deploys, OR
- Hit the live URL via Claude in Chrome and check content.
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.
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.
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).
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.
They re-trigger GSC "multiple aggregate ratings" errors on best-roofers pages. Only aggregateRating belongs inside a RoofingContractor.
Flipping it has caused full-site redirect loops twice. Don't touch without extreme care.
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:
- An entry in
scripts/seo-titles.jsonunderstaticTitles(title, description, canonical). The prerender'sgetSEOMetadata()reads from this JSON file and HARD-FAILS the build withMISSING METADATA FOR ROUTEif 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 tosrc/config/seoTitles.ts(which is for the React runtime, not the prerender). - Either an explicit
<Route>inApp.tsxor a registered fallback thatStaticContentPagehandles 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). - An entry in
SEO_TITLESinsrc/config/seoTitles.tsmirroring the prerender data (title, description, canonical). Without this,NuclearMetadatacallsgenerateSEOMetadata, the lookup falls through to the fallback, andisFallback's prerender-preserving guard kicks in — but only as a safety net. NOTE: this is a SEPARATE file fromscripts/seo-titles.json. The TS file is for runtime React; the JSON file is for the build-time prerender. Both need a matching entry. - The slug added to
SPA_SHELL_VICTIM_SLUGSinprerender-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.
node --check scripts/prerender-static.mjs— syntax sanity check.- If React changed:
npx tsc --noEmit -p tsconfig.app.json(when deps are available locally). - Commit + push to a feature branch; open PR via
gh pr create. - Wait 2–3 minutes after merge to
mainfor Netlify deploy. - Verify live via Claude in Chrome — navigate to the changed page, view source, confirm the
<script type="application/ld+json">block is correct. - Paste the LIVE URL into https://search.google.com/test/rich-results and confirm expected schema types are detected.
- In GSC, hit Request Indexing on changed URLs.
- After 24 hrs, check GSC Enhancements (FAQ, Breadcrumbs, Review snippets) for regressions.
- If a batch of URLs changed, submit the updated sitemap in GSC and verify IndexNow submitted (
scripts/submit-indexnow.mjsoutput appears in Netlify build log).
These apply to every content change on the site:
- 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.
- 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.
- 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.
- Don't introduce new problems. The site was working. Verify before pushing.
- April 2026 pivot — flipped all
/locations/*titles from "Roofing Services in X" to "Roof Replacement in X" (commit 55a949de). - 2026-04-06 — removed inline
reviewarrays 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/:citypage viaLOCATIONS.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) —
topRooferMatchhandler fixed broken slug titles on top-5-roofer service-area pages. - PR-13 (merged, 2026-04-18) — trailing-slash canonical mismatches on
/locations/service-areasand/sitemapfixed. - 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/answerNodessplit 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 ofgenerateStaticFiles(), asserts each of 44 audit-flagged slugs has adist/<slug>/index.htmlwith a non-Vite-shell<title>. Throws to fail the Netlify build if any are missing or stale. Override: envSPA_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 explicitSEO_TITLESentries mirroring prerender data, (b) addedisFallback?: booleantoSEOMetadataso NuclearMetadata can preferwindow.__PRERENDERED_TITLE__when the SEO lookup falls through. Architectural fix protects future regressions. See §7 and §17.
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%.
- 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)
Resolved:
45 geo-page duplicate titles— fixed in PR-24 (build-time verifier) + PR-25 (NuclearMetadataisFallbackguard + 44SEO_TITLESentries). 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
| 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 |
|
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 |
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.
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.
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:
main.tsxsnapshots__PRERENDERED_TITLE__and__PRERENDERED_HTML__from the prerendered DOM.- React Router runs, hits the catchall, mounts
StaticContentPage. NuclearMetadata(mounted in the layout) runs itsuseEffectand callsgenerateSEOMetadata(path).
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.
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.
Always do all three of:
- Add the slug entry to a data array in
prerender-static.mjswithslug,title,description,h1,intro,sections(or whatever the surrounding array's shape is). This writes the static HTML. - Add the slug to
SEO_TITLESinsrc/config/seoTitles.tswith title and description that mirror the prerender data exactly. This fixes the JS-render path. - Add the slug to
SPA_SHELL_VICTIM_SLUGSinprerender-static.mjsso 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.
scripts/prerender-static.mjs— writes static HTML, runs verifier (lines ~2080-2230 declareSPA_SHELL_VICTIM_SLUGS+verifySpaShellArtifacts()).src/config/seoTitles.ts— declaresisFallback?: boolean, sets it on fallback return.src/components/NuclearMetadata.tsx— readsisFallback+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.
If you need to ship around the verifier without removing the check, set Netlify env:
SPA_SHELL_VERIFIER=warn
Default is fail. Use sparingly.