Property Search Audit Playbook
⚠️ SUPERSEDED 2026-08-01 — DO NOT USE AS METHOD
Use the
/search-auditskill (.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/staticbuild 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:
/templatesshowed 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 (
/bloggingvs/blog,/contact-usvs/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:
| Signal | Note |
|---|---|
BreadcrumbList | Was absent despite visible breadcrumbs |
dateModified | Was absent |
timeRequired, speakable | Cheap, help voice/AI readers |
| Citable stat block | Answer engines quote specific numbers far more readily than prose. Every figure must be read from the record — never estimated |
| FAQ depth | 2.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:
authorstaysOrganizationreviewedBycarries thePerson+ 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-bare | document.title |
|---|---|---|
| domcontentloaded | present | correct |
| after hydration | gone | reverted |
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 bucket | Meaning | What moves it |
|---|---|---|
| Discovered – currently not indexed | Google knows the URL, has not fetched it | Crawl prioritisation — honest lastmod, internal links, fewer dead ends |
| Crawled – currently not indexed | Google fetched it and declined | Content 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.ymlrequiresknowledge/tests/registry.yamlto be touched in the samechurchwiseai-webPR, but that file lives in the separateChurchWiseAI/knowledgerepo and is never checked out during that build — soregistry_touchedcan only ever be 0 and every change is forced ontotest-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:
- VALIDATE FIX on a specific fixed error report (e.g. "Not found (404)")
- 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.tslooked corrupted in terminal output and was clean UTF-8. src/test/unit/usesnode:test, not vitest (286 files vs 0). Vitest is not a dependency;npx vitestsilently downloads it and the tests then failtsc. Match the surrounding convention.