Skip to main content

Property Search Audit Playbook

⚠️ SUPERSEDED 2026-08-01 — DO NOT USE AS METHOD

Use the /search-audit skill (.claude/skills/search-audit/SKILL.md) instead.

Running this playbook on a second property produced three confident wrong diagnoses in two days, all with the same shape: an aggregate number read as a causal verdict without opening what it aggregated. This document could not prevent that, because its stated design principle — "every rule below is written against a specific mistake that was actually made" — only ever defends against errors that have already happened, and it freezes the author's then-current beliefs as fact.

A specific error is preserved below as a worked example. Stage 5.3 and the Stage 0 text present sermonwise's "349 crawled-and-declined" as a content-quality verdict. Sampling that list at six points on 2026-08-01 found ~250 of the 349 are _next/static build assets; only ~100 are real pages. The same error then recurred on churchwiseai.com (60 quoted; 8 real content pages). Rule 5.3's table is the specific text that caused it — it tells the reader "Crawled – currently not indexed" means "Google fetched it and declined", with no instruction to check what is in the bucket first.

Retained only for the host-routing, hydration and redirect-verification specifics in Stages 1–4, which were verified against live hosts and still hold. Every interpretive claim about indexing buckets, crawl budget or content quality in this document is untrustworthy.

Applies to: any property this portfolio serves — churchwiseai.com, pewsearch.com, illustratetheword.com, sermonwise.ai, sharewiseai.com, wiseaiagency.com, funeralwiseai.com, veterinarywiseai.com, realtywiseai.com, comparelocal.ca, livingword.bible, johnmoelker.com, and every {slug}.john316.church Pro Website.

Source: the sermonwise.ai audit of 2026-07-31 (churchwiseai-web PRs #1307, #1308, #1309, #1310, #1311; knowledge PRs #141, #142). Read DECISION_LOG.md 2026-07-31 for the narrative.

Why this exists: that audit produced two confident diagnoses that were WRONG and had to be retracted to the founder, plus two "fixed" claims that were not fixed. Every rule below is written against a specific mistake that was actually made. Follow the order — the ordering is the point.


Stage 0 — Establish ground truth BEFORE forming any theory

Rule 0.1 — GSC is the only source of truth for search. PostHog is not.

On sermonwise.ai, PostHog implied Google sent 38 visitors in 60 days while Bing sent 193, and that became a confident "Google is filtering us as scaled content" diagnosis delivered to the founder. Google Search Console then showed 2.3K impressions, average position 12.5, best page at 6.9% CTR — nothing was being filtered. PostHog only fires on hydrated client-side pageviews and was heavily bot-contaminated, so it undercounted Google badly.

Never diagnose ranking, indexing or search visibility from PostHog, Vercel Analytics, or server logs. Get GSC first. If you do not have GSC access, say so and ask — do not substitute a proxy and present it as a finding.

GSC is reachable in the browser with the founder's session (/goal-style ask: "open GSC and I'll drive"). Useful direct URLs, substituting the property:

Performance https://search.google.com/search-console/performance/search-analytics?resource_id=sc-domain%3A<domain>&num_of_months=3
Page indexing https://search.google.com/search-console/index
Sitemaps https://search.google.com/search-console/sitemaps?resource_id=sc-domain%3A<domain>
Filter query ...&query=!<exact> (exact) ...&query=*<substring> (contains)
Filter page ...&page=!<full-url>
Breakdown ...&breakdown=page | &breakdown=query

Rule 0.2 — Strip bots before quoting any analytics number.

The sermonwise "3.6× month-over-month growth" was a five-day burst on one page: 1,430 "visitors", 100% Chrome / Desktop / Linux, one pageview each, spread across US/UK/CA/PL/MX/SG/SE/IN, then zero for eight days. Real traffic had fallen 482 → 174.

Always break analytics down three ways before believing a trend:

-- PostHog HogQL. Bot signature = one OS dominating with 1 pageview per person.
select properties.$os as os, properties.$device_type as dev,
count(distinct distinct_id) as ppl
from events where event='$pageview' and properties.$host like '%<domain>%'
and timestamp > now() - interval 120 day
group by os, dev order by ppl desc

Then re-run the trend excluding the signature: and not (properties.$os='Linux' and properties.$device_type='Desktop'). Also break down by day — a monthly aggregate hides a five-day spike.

Rule 0.3 — Split brand from non-brand before reading anything.

On sermonwise, 1,108 of 2,300 impressions were the query sermonwise at position 1.6 with 5 clicks — sitelink impressions and rank-tracker bots, not demand. A brand SERP mints an impression for every sitelink URL, so one search can inflate ten pages at once.

Before optimising any page for CTR, break its impressions down BY QUERY. A high-impression / low-CTR page is usually a brand sitelink, not a failing title. This was the second retracted recommendation: /templates showed 449 impressions at 0.22% CTR and looked like the highest-leverage fix on the site — 429 of those impressions were the brand term. A title rewrite would have done nothing.

Record the honest figure: non-brand impressions and clicks per quarter.


Stage 1 — Host-routing correctness (highest defect yield)

Every property served by churchwiseai-web via hostname rewrite inherits this whole bug class. It produced 4 of the 5 real defects found on sermonwise.

Rule 1.1 — Enumerate leaking 404s by probing BOTH hosts, then check GSC.

The global ChurchWiseAI Header/Footer still renders into the DOM on brand hosts (hidden by CSS via BrandBareMarker), and hidden links are still crawled. Any ChurchWiseAI route without a /sermons-style twin dead-ends in a 404.

# Every top-level route on the parent, tested against the brand host.
for u in $(ls -d src/app/*/ | sed 's|src/app/||;s|/$||' | grep -vE '^(api|_|\[)'); do
b=$(curl -s -o /dev/null -w "%{http_code}" "https://<brand-host>/$u")
c=$(curl -s -o /dev/null -w "%{http_code}" "https://churchwiseai.com/$u")
[ "$b" = "404" ] && [ "$c" = "200" ] && echo "LEAKING: /$u"
done

Then cross-check against GSC → Page indexing → "Not found (404)". On sermonwise I enumerated from the hidden nav markup, fixed 5 paths, and declared it done. GSC had 21, including four /blog/<post> articles that needed prefix matching. When a fix is driven by a list, get the actual list.

Fix in middleware with a pure, testable predicate (see churchwiseai-web/src/lib/churchwiseai-only-paths.ts), 308 to the parent host.

The dangerous direction is a false positive. Capturing a path that really exists on the brand host bounces its own visitors away. The test must assert the property's real routes are NEVER captured, plus look-alikes (/blogging vs /blog, /contact-us vs /contact).

Rule 1.2 — Check the 404 page's brand on the real host.

app/not-found.tsx renders for unmatched URLs and sits OUTSIDE the brand segment layouts, so it gets neither their BrandBareMarker nor their metadata. sermonwise.ai/<missing> served the full ChurchWiseAI header, the tagline "Helping Churches Bring People to Jesus", the ChurchWiseAI <title>, and /contact + /demo links that 404 on that host.

Rule 1.3 — Look for slug mismatches between a page and its URL parent.

Tradition pages are keyed by baseTemplateId; for two occasions that differed from the occasion page slug, so the breadcrumb rendered at y=67 — above the fold — pointed at a 404 on 34 pages, including the property's most-visited page. Detect generically:

# Every URL that appears as a link target must resolve.
curl -s <page> | grep -oP '(?<=href=")/[^"]*' | sort -u | while read u; do
printf "%-60s %s\n" "$u" "$(curl -s -o /dev/null -w '%{http_code}' "<host>$u")"
done | grep -v ' 200$'

Fix by redirecting the alias at the edge, never by renaming an indexed URL.


Stage 1b — Conversion and UX audit

Traffic that arrives and leaves is worth nothing. On sermonwise the real single-page session rate was 84% (96% before stripping bots), and the reason was sitting in plain sight once measured.

Rule 1b.1 — Measure CTA scroll depth, do not eyeball it.

On the property's highest-traffic page the only in-content offer sat at y=4067 of a 4516px page — 90% scroll depth. Organic visitors landed, read, and were asked for nothing until they had scrolled past 4,000 pixels.

// Every CTA with its absolute position, so "below the fold" is a number.
await page.evaluate(() => [...document.querySelectorAll('a,button')]
.map(e => ({ text: e.innerText.trim().slice(0,40),
href: e.getAttribute('href'),
y: Math.round(e.getBoundingClientRect().top + window.scrollY) }))
.filter(x => x.text));

Compare each y against document.body.scrollHeight. Anything past ~40% on a page that organic traffic lands on is effectively invisible.

Rule 1b.2 — Full-page screenshots lie. Probe computed styles instead.

A fullPage capture of the sermonwise homepage and pricing page showed hundreds of pixels of blank white, and it nearly went into the report as a rendering bug. The content was present at opacity: 1 the whole time — fullPage capture simply does not paint everything.

// Sample at two timepoints and assert CHANGE, not presence.
const probe = () => page.evaluate(() => {
const els = [...document.querySelectorAll('section, main > div')];
return els.filter(e => e.getBoundingClientRect().height > 200)
.map(e => ({ h: Math.round(e.getBoundingClientRect().height),
op: getComputedStyle(e).opacity,
txt: (e.innerText||'').trim().slice(0,40) }));
});
// before scroll -> scroll through -> after scroll

Then capture viewport tiles while scrolling, not one tall image. Also run the page with reducedMotion: 'reduce' — scroll-reveal animations that never fire leave real users staring at blank sections.

Rule 1b.3 — Check every visible link resolves, and check visibility properly.

Distinguish visible broken links from ones buried in display:none chrome — they need different fixes and different urgency. On sermonwise I initially reported four 404 links as a broken user path; they were inside hidden ChurchWiseAI chrome, so users never saw them (SEO hygiene, not a user outage). The genuinely user-facing 404 was the breadcrumb at y=67.

// Walk ancestors — an element can be display:none via any parent.
await page.evaluate(() => [...document.querySelectorAll('a')].map(a => {
let el = a, hidden = false;
while (el) { const st = getComputedStyle(el);
if (st.display === 'none' || st.visibility === 'hidden') { hidden = true; break; }
el = el.parentElement; }
return { href: a.getAttribute('href'), text: a.innerText.trim().slice(0,30), hidden };
}));

Rule 1b.4 — Mobile overflow, console errors, and load time.

// Page-level horizontal scroll is a real defect; a child overflowing an
// overflow-hidden parent usually is not. Check the document, then the culprits.
await page.evaluate(() => ({
docW: document.documentElement.scrollWidth, winW: window.innerWidth,
wide: [...document.querySelectorAll('*')]
.filter(e => e.getBoundingClientRect().right > window.innerWidth + 2)
.slice(0,4).map(e => e.tagName + '.' + String(e.className).slice(0,40)),
}));

Ignore ad/analytics console noise (doubleclick, googleads) — it is present on every property and is not a defect.

Rule 1b.5 — Report the bounce rate bot-adjusted, and say which it is.

96% single-page sounds like a broken site; 84% is a content site with a weak next step. Quoting the un-adjusted figure would have misdirected the whole conversation.


Stage 2 — Sitemap honesty

Rule 2.1 — lastmod must be per-page and real.

All 677 sermonwise URLs carried one identical lastmod regenerated on every next build. With several deploys a day, Google was told all 677 pages changed several times daily when the content had not changed since March. Google's documented response to unreliable lastmod is to distrust the sitemap — which fits a large "Discovered – currently not indexed" backlog.

curl -s https://<host>/sitemap.xml | grep -oP '(?<=<lastmod>)[^<]+' \
| cut -c1-10 | sort | uniq -c | sort -rn

One value covering every URL = broken. Wire each entry to its own authored date. Set changeFrequency to the truth (yearly for static content), not aspiration.

Rule 2.2 — When you edit a page, its date must move with it.

Adding the baccalaureate content to graduation-sermon initially left the sitemap reporting the old tradition date, silently reintroducing the exact dishonesty just fixed. Any date helper must consider the edited entity's own date, not just its children.


Stage 3 — GEO/AEO signals

Follow geo-aeo-spec.md for the full spec. The additions that mattered here:

SignalNote
BreadcrumbListWas absent despite visible breadcrumbs
dateModifiedWas absent
timeRequired, speakableCheap, help voice/AI readers
Citable stat blockAnswer engines quote specific numbers far more readily than prose. Every figure must be read from the record — never estimated
FAQ depth2.1 FAQs/page was thin. Expanding is the single highest-leverage AEO change; a >100-page batch needs claude -p and founder cost approval (Rule #5)

Rule 3.1 — E-E-A-T on AI-generated content: reviewedBy, never author.

The founder asked to be named as author for E-E-A-T. Declined, because the content is generatedWith: claude-opus-4-6 — naming a human author would be a false claim on 306 pages and contradicts our own published "AI must tell the truth" position. He then confirmed he reviews them, which is a different and true claim, so:

  • author stays Organization
  • reviewedBy carries the Person + credentials
  • a visible byline states the same thing to the reader

Schema asserting something the reader cannot see is both a Google structured-data violation and, for AI content, a trust problem. Assert the schema field and the visible byline TOGETHER in the test, so that if review ever stops, removing one forces removing the other.


Stage 4 — Verification (where the audit nearly shipped two lies)

Everything in qa-checklist.md applies. These three are the ones this audit learned the hard way.

Rule 4.1 — Verify on the REAL host. A preview proves nothing here.

*.vercel.app previews skip the hostname-rewrite middleware, so every host-routing assertion passes vacuously on a preview — worse than failing, because it looks green. State this at the top of any such spec.

Rule 4.2 — Sample at ≥2 timepoints. Hydration undoes things.

The brand-correct 404 was reported FIXED and was not. A pre-paint inline script set the marker and title correctly — then React reconciled <body>, removed the node it had not created, and Next's metadata reset the title.

Timepoint.brand-baredocument.title
domcontentloadedpresentcorrect
after hydrationgonereverted

For anything a client script mutates outside React's tree, assert AFTER hydration. The fix needs a React-owned twin inside the component tree; a pre-paint script alone can never hold.

Rule 4.3 — A green build is not a working redirect.

permanentRedirect() inside a statically prerendered page does not emit a 308. Next prerenders an HTML page whose RSC payload redirects on the client — invisible to crawlers — and mints a phantom page competing with the canonical URL. pnpm build was green the whole time; only the deployed preview showed it. Redirects belong in middleware, and the test must assert status === 308.

Beware ordering: an edge 404 guard that runs first will hard-404 the alias before the redirect ever runs.

Rule 4.4 — Distinguish your regression from a pre-existing one.

When a spec fails, run the same spec against production as a control before concluding. A SermonWise checkout spec failed on preview and passed 4/4 on prod: the Stripe price IDs are Production-scoped env vars, so that spec cannot run on a preview at all. Likewise the Vercel OOM build failures were pre-existing (NODE_OPTIONS=--max-old-space-size=6144 is inherited by every forked worker, so each believes it may take 6 GB in an 8 GB container) — not a Vercel defect, and the founder was talked out of filing a support ticket claiming otherwise.


Stage 5 — What to build next (evidence, not instinct)

Rule 5.1 — Read the winners before proposing pages.

GSC → Performance → Pages. On sermonwise, occasion pages beat permutations decisively: /templates/fathers-day-sermon earned 6.9% CTR while the tradition variant pages earned almost nothing, and the page PostHog called the site's #1 earned zero search impressions (it was entirely bots).

Rule 5.2 — Deepen one page before adding a second.

Founder's call on baccalaureate sermon (24 impressions, 0 clicks, position 35.2) and it was correct: the graduation page already contained the term and still ranked page 4, so the word was never missing — more mentions would have been keyword stuffing. A near-duplicate page would have competed with its own parent and added to the 349 URLs Google had already crawled and declined.

The move is depth, not synonyms: a real new section, substantive FAQs carrying non-obvious information a competitor cannot copy from a template site, and schema that grows with it.

Rule 5.3 — Know which not-indexed bucket you are in.

GSC bucketMeaningWhat moves it
Discovered – currently not indexedGoogle knows the URL, has not fetched itCrawl prioritisation — honest lastmod, internal links, fewer dead ends
Crawled – currently not indexedGoogle fetched it and declinedContent quality only. Submission does nothing

Sermonwise: 195 discovered vs 349 crawled-and-declined. The lastmod fix addresses the first bucket and cannot touch the second. Say so plainly rather than implying a sitemap fix will move both.

Rule 5.4 — Verify a "ready-made" dataset before promising it.

sai_lectionary_calendar looked like a shippable 208-page family (2023–2027, Years A/B/C, 832 readings) and was recommended to the founder as such. Checking it: 127 of 208 Sundays share duplicated reading sets (26 consecutive identical 2027 Sundays), and only 2 are both correct and future-dated. Count the distinct, correct, in-date rows before proposing any data-driven page family.


Stage 6 — Lock it down

Write one contract spec per property covering redirects, real routes still 200, invalid slugs still 404, sitemap honesty, 404 branding after hydration, and schema validity. Model: churchwiseai-web/e2e/contracts/sermonwise-host-routing.contract.spec.ts (42 assertions).

BASE_URL=https://<real-host> npx playwright test e2e/contracts/<property>-host-routing.contract.spec.ts

Register it in knowledge/tests/registry.yaml including the must-run-on-real-host constraint, and append findings to DECISION_LOG.md + founder items to FOUNDER_ACTIONS.md.

Known gate flaw: the SermonWise test-coverage-gate.yml requires knowledge/tests/registry.yaml to be touched in the same churchwiseai-web PR, but that file lives in the separate ChurchWiseAI/knowledge repo and is never checked out during that build — so registry_touched can only ever be 0 and every change is forced onto test-coverage-override. Update the real registry anyway and cite it in the reason comment. Fix the gate before copying this pattern to another property.


Founder-facing reporting

  • Lead with the answer, then the evidence.
  • Correct your own wrong findings loudly and early. Two headline diagnoses in this audit were retracted; saying so plainly cost nothing and stopped the founder acting on them.
  • Give honest expectations. "This should move position; it will not make graduation a traffic pillar" is worth more than an optimistic number that looks like a miss in September.
  • Manual GSC actions worth recommending, and only these two:
    1. VALIDATE FIX on a specific fixed error report (e.g. "Not found (404)")
    2. Resubmit the sitemap once, after deploy Then spend the 10/day URL-inspection quota on the pages with proven demand — never on the backlog.

Gotchas

  • git add -A src/ swept the founder's untracked local working files into a commit and broke a Vercel build. Stage explicit paths.
  • Full-page Playwright screenshots do not paint everything; a blank band is a capture artifact. Probe computed styles at two timepoints instead.
  • Check file encoding before "fixing" mojibake — life-events.ts looked corrupted in terminal output and was clean UTF-8.
  • src/test/unit/ uses node:test, not vitest (286 files vs 0). Vitest is not a dependency; npx vitest silently downloads it and the tests then fail tsc. Match the surrounding convention.