Technical SEO as an Ops Change: A Projected Before/After Model for a Real-Estate Agency's Listing Platform
A reproducible model of what happens to crawl budget, index bloat, time-to-index, and lead throughput when a listings platform fixes lifecycle status codes, sitemaps, structured data, and Core Web Vitals — plus the runbooks that change day to day.
Technical SEO on a real-estate platform is not a content problem. It's an inventory-synchronization problem. Listings enter and leave the MLS continuously, agent and area pages compete for the same queries, and faceted search generates URL space faster than any crawler can consume it. When the data pipeline and the HTTP layer disagree about what a URL means, crawlers spend their budget on pages that no longer sell houses.
Everything below is a projected model with illustrative numbers, not a client case study with real analytics. The value is in the model itself: the assumptions are explicit, the arithmetic is in ~40 lines of Python, and you can re-run it against your own server logs and Search Console exports in an afternoon.
The before-state: what a 60k-URL listings site usually looks like
Assume a mid-size agency with a regional MLS feed. Baseline inventory and infrastructure:
- 8,400 active listings, updating on average every 26 hours (price changes, status flips, photo additions)
- 3,400 off-market listings left at HTTP 200 because nobody wrote the removal path
- 2,100 agent/branch pages, 300 blog/area-guide pages
- 38,000 facet URLs (
/search?beds=3&price_to=650000&sort=…) all indexable - Total URL space ≈ 53,000; Google reports ~24,600 indexed
The operational symptoms that follow from that shape:
- Crawl hits concentrate on facet permutations and dead listings, so a new listing waits days to be discovered.
- Facet pages cannibalize the canonical city page for "3-bed homes in ".
- The listing detail page is the LCP element's worst enemy: a 2.4 MB hero image, three render-blocking scripts, and a client-side price widget that hydrates after paint.
Measured over a 30-day window, the baseline looks like this:
| Metric | Before (measured baseline) |
|---|---|
| Crawl hits/day (Googlebot + Bingbot) | 1,450 |
| Share of hits on active inventory | 38% |
| Indexable URLs | 24,600 |
| Median time-to-index for a new listing | 6.5 days |
| LCP p75 (mobile) | 4,100 ms |
| URL groups passing CWV | 29% |
| Impressions/month | 512,000 |
| CTR | 1.9% |
| Session → lead rate | 3.1% |
The model: turning "SEO improvements" into a delta you can defend
Instead of arguing about whether the work is worth it, encode the funnel. Each stage is a separate multiplier, so a bad assumption in one stage doesn't hide inside another.
# illustrative_model.py — all numbers are projections, not results
before = dict(
crawl_hits_day=1450, useful_ratio=0.38,
lcp_p75_ms=4100, cwv_pass=0.29,
indexed_active_share=0.52, # share of active listings indexed
impressions=512_000, ctr=0.019, session_to_lead=0.031,
)
after = dict(
crawl_hits_day=1800, useful_ratio=0.71, # +24% hits, faster pages
lcp_p75_ms=2100, cwv_pass=0.78,
indexed_active_share=0.93,
impressions=628_000, ctr=0.026, session_to_lead=0.038,
)
def funnel(d):
useful = d["crawl_hits_day"] * d["useful_ratio"]
sessions = d["impressions"] * d["ctr"]
leads = sessions * d["session_to_lead"]
return dict(useful_hits=round(useful), sessions=round(sessions), leads=round(leads))
b, a = funnel(before), funnel(after)
print(b, a)
# {'useful_hits': 551, 'sessions': 9728, 'leads': 302}
# {'useful_hits': 1278, 'sessions': 16328, 'leads': 620}
Projected deltas: 2.3× useful crawl, +68% sessions, ~2.05× leads/month, with time-to-index modeled separately at 6.5 days → 9 hours. The impressions and ctr terms are the ones a skeptic should attack first — they depend on indexation coverage and on the listing page actually being competitive, which is what the next section addresses.
The five changes that move those numbers
1. A lifecycle status-code policy. The single highest-leverage change. A sold listing should not 301 to the search page; that hands crawlers a relevance mismatch and keeps a dead URL alive in the index.
// Express: status code policy for listing lifecycle
const GONE = new Set(['off_market', 'expired', 'withdrawn', 'pending_archive']);
app.get('/listing/:mlsId', async (req, res) => {
const l = await listings.byMlsId(req.params.mlsId);
if (!l) return res.sendStatus(404);
if (GONE.has(l.status)) {
res.set('Cache-Control', 'public, max-age=86400');
return res.status(410).send(renderGoneWithSimilar(l)); // keep internal links to area pages
}
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=600');
res.send(renderListing(l));
});
410 removes ~3,400 URLs from the crawlable set without orphaning the internal link graph.
2. Sharded sitemaps driven by updated_at. Regenerate shards on write, not on a nightly cron, and put the truth in lastmod.
-- sitemap shard query
SELECT mls_id, updated_at
FROM listings
WHERE status = 'active'
ORDER BY updated_at DESC
LIMIT 5000 OFFSET $1;
Emit a /sitemap.xml index pointing at eight listing shards plus agents/areas/blog. A nightly sweep pushes only changed shards, and a job feeds the Indexing API for the ~200 highest-intent listings per day.
3. Facets: crawl paths yes, index no. Keep ?beds= crawlable only when it has a canonical landing page; noindex, follow everything else and drop the rest into robots.txt disallow. This is what moves indexed_active_share from 0.52 to 0.93 — not because more pages get indexed, but because fewer irrelevant ones compete.
4. Structured data with a CI gate. Emit RealEstateListing server-side and fail the build when it drifts:
{
"@context": "https://schema.org",
"@type": "RealEstateListing",
"url": "https://example.com/listing/MLS-12345",
"datePosted": "2025-02-11",
"availability": "https://schema.org/InStock",
"offers": { "@type": "Offer", "price": 649000, "priceCurrency": "USD" }
}
5. A performance budget that blocks merges. CWV only moves when it's enforced, not when it's reported:
{
"ci": {
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"categories:performance": ["error", { "minScore": 0.85 }]
}
}
}
}
Serve hero images through a resizing proxy with srcset + AVIF, preload the LCP image, and defer the mortgage calculator into an intersection-observer island.
What actually changes in day-to-day operations
The technical work is a two-sprint project. The operational change is permanent:
- Ingestion team owns freshness now. New listing → sitemap shard regenerated within 60 seconds → Indexing API push. SLA: indexed in < 12 hours for top-200 listings. That SLA lives on the same dashboard as feed latency.
- A nightly 410 sweep replaces manual index requests. The old routine of "someone pings Google for delisted properties" (≈11 engineer-hours/week) drops to ~3 hours of reviewing anomalies.
- Weekly crawl diffing becomes a standup artifact. Anyone can run it:
zcat access.log.*.gz \
| grep -E 'Googlebot|bingbot' \
| awk '{print $7}' \
| sed -E 's#/listing/[^/]+#/listing/*#' \
| sort | uniq -c | sort -rn | head -30
If useful_ratio (hits on /listing/* + area pages vs. total hits) drops below 0.6, someone investigates before the monthly review.
- Marketing stops arguing about "SEO" and starts owning distribution. Once listings index in hours and pages pass CWV, the constraint moves to the top of the funnel: agent social posts, area-guide cadence, follow-up on new listings. That's a content-ops problem with a publishing calendar, and it needs the same SLA discipline.
Guardrails: how you'd falsify this model in 30 days
Treat the projection as a hypothesis with kill criteria:
- If
indexed_active_sharehas not crossed 0.80 in 30 days, the facet policy is leaking — auditnoindexheaders on canonical facet landing pages first. - If crawl hits rise but
useful_ratiodoesn't, check for a redirect chain in the listing route (a 302 onwwwplus a 301 on trailing slash will eat the gains). - If leads scale slower than sessions, the listing page copy or the form is the bottleneck, not indexing. Segment by device — mobile CWV regressions show up as a CTR gap before they show up in rankings.
- Re-run the funnel with measured numbers monthly; keep the old dict in git so the delta is auditable.
None of this is exotic. It is a status-code policy, a sitemap contract, a handful of CI assertions, and a nightly job — the same discipline you'd apply to any high-churn data product. The reason it looks like a marketing win is that crawlers, like your users, only have so much attention per day.
Try it out
Skip the trial-and-error phase. The ready-to-use Social Media Manager Prompts is already built — grab it here: https://toptoday.pw/go/krknlj
🔗 https://toptoday.pw/go/krknlj
Теги для публикации: seo, webdev, performance, javascript, python, architecture, realestate, casestudy
🔗 Useful tools (affiliate links)
- Keyword Insights — AI-powered keyword clustering for SEO: https://toptoday.pw/go/keyword_insights?utm_source=devto&utm_medium=article&utm_campaign=technical-seo-as-an-ops-change-a-projected-before-after-mode
- Merchynt — local SEO & Google Maps rankings: https://toptoday.pw/go/merchynt?utm_source=devto&utm_medium=article&utm_campaign=technical-seo-as-an-ops-change-a-projected-before-after-mode
- TopToDay — our AI tools platform: https://toptoday.pw/?ref=goose-seo&utm_source=devto&utm_medium=article&utm_campaign=technical-seo-as-an-ops-change-a-projected-before-after-mode
🎯 High-Converting Landing Page (Done-for-You) — https://toptoday.pw/go/landing-page-done-for-you?utm_source=devto&utm_medium=article&utm_campaign=technical-seo-as-an-ops-change-a-projected-before-after-mode
- 📲 Telegram: https://t.me/toptodayai
- 🌐 https://toptoday.pw/seo/
Top comments (0)