The model-specific stripping pages feature expands our SEO hierarchy from 2 levels (Pillar → Make) to 3 levels (Pillar → Make → Model). This creates highly targeted landing pages for specific vehicle models being stripped for spares.
Purpose:
Status: ✅ IMPLEMENTED (December 2024). Dynamic route at src/pages/[make]-[model]-stripping-for-spares.astro (903 lines).
Agent: Use seo-model-pages agent for model page creation, analysis, and optimization. See .claude/agents/seo-model-pages.md.
URL Pattern: /{make}-{model}-stripping-for-spares/
Examples:
/toyota-quantum-stripping-for-spares//bmw-3-series-stripping-for-spares//mercedes-benz-c-class-stripping-for-spares//vehicles-stripping-for-spares/ (Level 1: Pillar Page)
├── /bmw-stripping-for-spares/ (Level 2: Make Page - EXISTS)
│ ├── /bmw-3-series-stripping-for-spares/ (Level 3: Model Page - TO BE BUILT)
│ ├── /bmw-1-series-stripping-for-spares/ (Level 3: Model Page - TO BE BUILT)
│ └── /bmw-x5-stripping-for-spares/ (Level 3: Model Page - TO BE BUILT)
├── /mercedes-benz-stripping-for-spares/ (Level 2: Make Page - EXISTS)
│ ├── /mercedes-benz-c-class-stripping-for-spares/ (Level 3: Model Page - TO BE BUILT)
│ └── /mercedes-benz-vito-stripping-for-spares/ (Level 3: Model Page - TO BE BUILT)
└── /renault-stripping-for-spares/ (Level 2: Make Page - EXISTS)
├── /renault-sandero-stripping-for-spares/ (Level 3: Model Page - TO BE BUILT)
├── /renault-clio-stripping-for-spares/ (Level 3: Model Page - TO BE BUILT)
└── /renault-kwid-stripping-for-spares/ (Level 3: Model Page - TO BE BUILT)
Existing Pages:
src/pages/vehicles-stripping-for-spares/index.astrosrc/pages/[make]-stripping-for-spares.astro (dynamic route)To Create:
src/pages/[make]-[model]-stripping-for-spares.astro (NEW dynamic route)Based on query: SELECT make, COUNT(*) FROM vehicles WHERE status IN ('available','active') GROUP BY make ORDER BY COUNT(*) DESC
| Make | Total Vehicles | Top Models |
|---|---|---|
| Mercedes-Benz | 201 | C-Class (37), Vito (13), Sprinter (11) |
| Renault | 158 | Sandero (33), Clio (30), Kwid (24) |
| Audi | 103 | A4 (18), A3 (12), Q5 (9) |
| BMW | 90 | 3 Series (24), 5 Series (8), X5 (6) |
| Hyundai | 81 | i20 (14), Getz (13), i10 (11) |
| Volkswagen | 67 | Golf (15), Polo (12), Jetta (8) |
| Kia | 42 | Picanto (10), Rio (7), Sportage (5) |
These models should get dedicated pages immediately (Phase 1):
| Make | Model | Vehicle Count | Priority | URL |
|---|---|---|---|---|
| Mercedes-Benz | C-Class | 37 | 1 | /mercedes-benz-c-class-stripping-for-spares/ |
| Renault | Sandero | 33 | 2 | /renault-sandero-stripping-for-spares/ |
| Renault | Clio | 30 | 3 | /renault-clio-stripping-for-spares/ |
| Renault | Kwid | 24 | 4 | /renault-kwid-stripping-for-spares/ |
| BMW | 3 Series | 24 | 5 | /bmw-3-series-stripping-for-spares/ |
Why 20+ vehicles?
Phase 2 expansion candidates:
| Make | Model | Vehicle Count |
|---|---|---|
| Audi | A4 | 18 |
| Renault | Megane | 18 |
| Volkswagen | Golf | 15 |
| Hyundai | i20 | 14 |
| Mercedes-Benz | Vito | 13 |
| Hyundai | Getz | 13 |
| Renault | Duster | 12 |
| Volkswagen | Polo | 12 |
| Audi | A3 | 12 |
| Hyundai | i10 | 11 |
| Kia | Picanto | 10 |
| Ford | Ranger | 10 |
Problem: Database has variant-specific names that create duplicate pages if not normalized.
Examples:
| Make | Raw Database Values | Should Normalize To | SEO URL |
|---|---|---|---|
| BMW | E90, E90 320i, F30 320i, 335i | ”3 Series” | /bmw-3-series-stripping-for-spares/ |
| BMW | E60, 520d, 530i | ”5 Series” | /bmw-5-series-stripping-for-spares/ |
| VW | Golf 4, Golf 5, Golf 7 GTI | ”Golf” | /volkswagen-golf-stripping-for-spares/ |
| Mercedes | C200, C220 CDI, C-Class | ”C-Class” | /mercedes-benz-c-class-stripping-for-spares/ |
| Audi | A4 B8, S4, A4 Quattro | ”A4” | /audi-a4-stripping-for-spares/ |
Impact Without Normalization:
/bmw-320i-stripping-for-spares/ (1 vehicle) - thin content/bmw-330d-stripping-for-spares/ (2 vehicles) - thin content/bmw-335i-stripping-for-spares/ (1 vehicle) - thin contentImpact With Normalization:
/bmw-3-series-stripping-for-spares/ (24 vehicles) - rich contentSolution: Use existing src/lib/normalizers/vehicleNormalizer.ts (see Model Normalization section below).
Modern SEO best practice: Soft silos with contextual cross-linking (not strict siloing).
Where: Breadcrumb + 1-2 mentions in body content
Example:
<!-- Breadcrumb (always present) -->
Home > Vehicles Stripping For Spares > <a href="/bmw-stripping-for-spares/">BMW Spares</a> > BMW 3 Series Spares
<!-- Body paragraph (contextual) -->
<p>Looking for other <a href="/bmw-stripping-for-spares/">BMW models being stripped</a>?
We also stock E90, F30, and G20 variants.</p>
Anchor Text Variety (avoid over-optimization):
Where: Breadcrumb only (don’t force body links)
Example:
Home > <a href="/vehicles-stripping-for-spares/">Vehicles Stripping For Spares</a> > BMW Spares > BMW 3 Series Spares
Rationale: The make page already links to pillar, so model → pillar link via breadcrumb provides the chain.
Where: “Related Models” section (below vehicle grid)
Example:
<section class="related-models">
<h2>Other Popular BMW Models Being Stripped</h2>
<ul>
<li><a href="/bmw-1-series-stripping-for-spares/">BMW 1 Series Stripping</a> (5 vehicles)</li>
<li><a href="/bmw-x5-stripping-for-spares/">BMW X5 Stripping</a> (3 vehicles)</li>
<li><a href="/bmw-5-series-stripping-for-spares/">BMW 5 Series Stripping</a> (8 vehicles)</li>
</ul>
</section>
Anchor Text Pattern: {Make} {Model} Stripping (natural, descriptive)
Display Vehicle Count: Helps users decide which link to click
Where: Contextual mentions in body content (sparingly)
Example:
<!-- On /bmw-3-series-stripping-for-spares/ -->
<p>If you're comparing German luxury sedans, check out our
<a href="/mercedes-benz-c-class-stripping-for-spares/">Mercedes-Benz C-Class inventory</a>
or <a href="/audi-a4-stripping-for-spares/">Audi A4 parts</a>.</p>
Use Cases for Cross-Silo Links:
Important: Only link when contextually relevant. Don’t force cross-links.
Where: Footer section of model pages
Example:
<section class="related-links">
<h3>Related BMW 3 Series Links</h3>
<ul>
<li><a href="/bmw-3-series-engines-for-sale/">BMW 3 Series Engines For Sale</a></li>
<li><a href="/bmw-scrap-yards/">BMW Scrap Yards Near You</a></li>
<li><a href="/sell-car-for-scrap/">Sell Your BMW 3 Series</a></li>
</ul>
</section>
Reuse Existing Component: src/components/MakeModelLinks.astro (already implements this pattern).
Modern Approach (what we’re doing):
Old Approach (what we’re NOT doing):
Model pages have 4 breadcrumb levels (vs. 3 for make pages):
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"item": {
"@id": "https://www.enginefinder.co.za/",
"name": "Home"
}
},
{
"@type": "ListItem",
"position": 2,
"item": {
"@id": "https://www.enginefinder.co.za/vehicles-stripping-for-spares/",
"name": "Vehicles Stripping For Spares"
}
},
{
"@type": "ListItem",
"position": 3,
"item": {
"@id": "https://www.enginefinder.co.za/bmw-stripping-for-spares/",
"name": "BMW Spares"
}
},
{
"@type": "ListItem",
"position": 4,
"item": {
"@id": "https://www.enginefinder.co.za/bmw-3-series-stripping-for-spares/",
"name": "BMW 3 Series Spares"
}
}
]
}
Difference from Make Pages: Make pages have 3 levels, model pages have 4 levels.
Purpose: Tells Google this page lists vehicles available for stripping.
{
"@context": "https://schema.org",
"@type": "ItemList",
"name": "BMW 3 Series Vehicles Stripping For Spares",
"description": "24 BMW 3 Series vehicles available for stripping and spare parts in South Africa",
"numberOfItems": 24,
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"item": {
"@type": "Car",
"name": "2015 BMW 3 Series 320i Stripping For Spares",
"url": "https://www.enginefinder.co.za/vehicles/bmw-3-series-320i-2015-abc123",
"vehicleModelDate": "2015",
"brand": { "@type": "Brand", "name": "BMW" },
"model": "3 Series",
"image": "https://example.com/image.jpg",
"itemCondition": "https://schema.org/UsedCondition",
"offers": {
"@type": "Offer",
"availability": "https://schema.org/InStock"
}
}
}
// ... more items
]
}
Reuse Existing Logic: See src/pages/[make]-stripping-for-spares.astro (lines 393-427) for reference implementation.
Before Deployment:
Testing Commands:
# Test breadcrumb schema exists
curl https://www.enginefinder.co.za/bmw-3-series-stripping-for-spares/ | grep -o '"@type":"BreadcrumbList"'
# Test item list schema exists
curl https://www.enginefinder.co.za/bmw-3-series-stripping-for-spares/ | grep -o '"@type":"ItemList"'
# Count schema blocks (should be 2+)
curl https://www.enginefinder.co.za/bmw-3-series-stripping-for-spares/ | grep -o 'application/ld+json' | wc -l
| File | Purpose | Status | Notes |
|---|---|---|---|
src/pages/[make]-[model]-stripping-for-spares.astro | Model page template | TO BE CREATED | Main implementation file |
src/lib/normalizers/vehicleNormalizer.ts | Model name normalization | EXISTS | Already handles BMW, VW, Mercedes, Audi, Toyota, Jeep, Fiat, Nissan |
src/pages/[make]-stripping-for-spares.astro | Make page template | EXISTS | Use as reference (393-427 for ItemList schema) |
src/pages/vehicles-stripping-for-spares/index.astro | Pillar page | EXISTS | Reference for silo structure |
src/components/MakeModelLinks.astro | Related links component | EXISTS | Reuse for footer cross-links |
src/components/Breadcrumb.astro | Breadcrumb navigation | EXISTS | Supports 4-level breadcrumbs |
src/components/internal-linking/SchemaMarkupGenerator.ts | Schema helper | EXISTS | Optional utility for generating JSON-LD |
Database stores variant-specific names that would create thin-content pages if used directly for SEO URLs.
Problem Without Normalization:
/bmw-320i-stripping-for-spares/ (1 vehicle) - thin content ❌/bmw-330d-stripping-for-spares/ (2 vehicles) - thin content ❌/bmw-335i-stripping-for-spares/ (1 vehicle) - thin content ❌Solution With Normalization:
/bmw-3-series-stripping-for-spares/ (24 vehicles) - rich content ✅File: src/lib/normalizers/vehicleNormalizer.ts
Already implemented for:
| Make | What Gets Normalized | Output | Example |
|---|---|---|---|
| BMW | Chassis codes (E90, F30, G20) | “3 Series”, “5 Series” | 320i E90 → 3 Series |
| Volkswagen | Golf variants (5, 6, 7, GTI) | “Golf” | Golf 7 GTI → Golf |
| Mercedes-Benz | Class variants (C200, C220 CDI) | “C-Class” | C220 CDI → C-Class |
| Audi | Series variants (A4 B8, S4) | “A4” | A4 B8 Quattro → A4 |
| Toyota | Hilux variants (GD-6, D4D, Raider) | “Hilux” | Hilux D4D → Hilux |
| Jeep | Cherokee variants (WJ, WK2) | “Grand Cherokee” | Grand Cherokee WK2 → Grand Cherokee |
| Fiat | Model families | Base model | 500 Pop → 500 |
| Nissan | Model families | Base model | Qashqai +2 → Qashqai |
// Input: "BMW 320i E90"
normalizeBMWModel("320i E90")
// Output: { baseName: "3 Series", variant: "320i", series: "E90" }
// SEO URL: /bmw-3-series-stripping-for-spares/
// Page Title: "BMW 3 Series Stripping For Spares | 24 Vehicles"
// Heading: "BMW 3 Series (E90, F30, G20) Stripping For Spares"
If a make needs normalization and doesn’t exist yet, add to vehicleNormalizer.ts:
export function normalizeRenaultModel(model: string): NormalizedModel {
const modelStr = model.trim().toUpperCase();
// Example: "SANDERO STEPWAY" → "Sandero"
if (modelStr.includes('SANDERO')) {
return {
baseName: 'Sandero',
variant: modelStr.replace('SANDERO', '').trim()
};
}
// Example: "CLIO RS" → "Clio"
if (modelStr.includes('CLIO')) {
return {
baseName: 'Clio',
variant: modelStr.replace('CLIO', '').trim()
};
}
// Default: return as-is
return { baseName: model };
}
When to Add Normalizers:
Target: Models with 20+ vehicles (guaranteed content depth)
Timeline: 1-2 days
| Make | Model | Vehicles | Priority | URL |
|---|---|---|---|---|
| Mercedes-Benz | C-Class | 37 | 1 | /mercedes-benz-c-class-stripping-for-spares/ |
| Renault | Sandero | 33 | 2 | /renault-sandero-stripping-for-spares/ |
| Renault | Clio | 30 | 3 | /renault-clio-stripping-for-spares/ |
| Renault | Kwid | 24 | 4 | /renault-kwid-stripping-for-spares/ |
| BMW | 3 Series | 24 | 5 | /bmw-3-series-stripping-for-spares/ |
Deliverables:
src/pages/[make]-[model]-stripping-for-spares.astroSuccess Criteria:
Target: Models with 10-19 vehicles
Timeline: 1 day
| Make | Model | Vehicles |
|---|---|---|
| Audi | A4 | 18 |
| Renault | Megane | 18 |
| Volkswagen | Golf | 15 |
| Hyundai | i20 | 14 |
| Mercedes-Benz | Vito | 13 |
| Hyundai | Getz | 13 |
| Renault | Duster | 12 |
| Volkswagen | Polo | 12 |
| Audi | A3 | 12 |
| Hyundai | i10 | 11 |
| Kia | Picanto | 10 |
| Ford | Ranger | 10 |
Deliverables:
Target: Models with 5-9 vehicles (evaluate case-by-case)
Timeline: Ongoing (monthly review)
Evaluation Criteria:
Examples of Good Long-Tail Candidates:
Examples of Poor Long-Tail Candidates:
Pages with too few vehicles provide:
Rule: Model pages require minimum 5 vehicles to exist.
Implementation:
// In getStaticPaths()
const vehicleCount = await getVehicleCountForModel(make, model);
if (vehicleCount < 5) {
// Don't generate this page - below threshold
continue;
}
Why 5?
For premium brands with higher search volume, use lower threshold:
const MIN_VEHICLES_HIGH_VALUE = 3; // BMW, Mercedes, Audi
const MIN_VEHICLES_STANDARD = 5; // Other makes
const threshold = ['bmw', 'mercedes-benz', 'audi'].includes(make.toLowerCase())
? MIN_VEHICLES_HIGH_VALUE
: MIN_VEHICLES_STANDARD;
if (vehicleCount < threshold) {
continue; // Skip page generation
}
Rationale: Premium brands have higher search volume, justifying pages with fewer vehicles.
Option 1: No Page (recommended)
Option 2: Redirect to Make Page
/bmw-1-series-stripping-for-spares/ → /bmw-stripping-for-spares/Option 3: Section on Make Page
/bmw-stripping-for-spares/?model=1-seriesRecommended: Option 1 (no page). Clean, simple, avoids complexity.
npm run build
Expected Output:
dist/ folder)Verify Generated Pages:
# Check if model pages exist
ls dist/bmw-3-series-stripping-for-spares/
ls dist/mercedes-benz-c-class-stripping-for-spares/
ls dist/renault-sandero-stripping-for-spares/
Tools:
Manual Validation Steps:
/bmw-3-series-stripping-for-spares/)Automated Check:
# Extract schema blocks
curl https://www.enginefinder.co.za/bmw-3-series-stripping-for-spares/ \
| grep -o '<script type="application/ld+json">.*</script>' \
| sed 's/<[^>]*>//g' \
| jq '.'
# Expected: 2 JSON-LD blocks (BreadcrumbList + ItemList)
After Implementation, test these URLs:
https://www.enginefinder.co.za/mercedes-benz-c-class-stripping-for-spares/
https://www.enginefinder.co.za/renault-sandero-stripping-for-spares/
https://www.enginefinder.co.za/renault-clio-stripping-for-spares/
https://www.enginefinder.co.za/renault-kwid-stripping-for-spares/
https://www.enginefinder.co.za/bmw-3-series-stripping-for-spares/
Checklist per URL:
Manual Check:
/bmw-3-series-stripping-for-spares/)Automated Check (use SEO crawler):
# Using Screaming Frog or similar
screaming-frog --crawl https://www.enginefinder.co.za/bmw-3-series-stripping-for-spares/
screaming-frog --export internal-links.csv
# Check for:
# - Outbound links to make page (should exist)
# - Outbound links to pillar page (via breadcrumb)
# - Outbound links to sibling models (should exist)
# - No 404s
Metrics to Check:
Run Lighthouse:
# Install Lighthouse CLI
npm install -g lighthouse
# Run audit
lighthouse https://www.enginefinder.co.za/bmw-3-series-stripping-for-spares/ \
--only-categories=performance,seo \
--output=json \
--output-path=./lighthouse-report.json
File: src/pages/sitemap.xml.ts (or sitemap.xml.astro)
Query Logic:
// Get all make/model combinations with 5+ vehicles
const { data: vehicles } = await supabase
.from('vehicles')
.select('make, model')
.in('status', ['available', 'active'])
.not('featured_image', 'is', null);
// Group by make + normalized model
const modelCounts = new Map<string, number>();
vehicles.forEach(v => {
const normalized = normalizeModel(v.make, v.model);
const key = `${v.make}:${normalized.baseName}`;
modelCounts.set(key, (modelCounts.get(key) || 0) + 1);
});
// Filter models with 5+ vehicles
const modelUrls = Array.from(modelCounts.entries())
.filter(([_, count]) => count >= 5)
.map(([key, _]) => {
const [make, model] = key.split(':');
return `/${slugify(make)}-${slugify(model)}-stripping-for-spares/`;
});
<url>
<loc>https://www.enginefinder.co.za/bmw-3-series-stripping-for-spares/</loc>
<lastmod>2024-12-13</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
| Page Type | Priority | Rationale |
|---|---|---|
| Homepage | 1.0 | Top-level entry point |
| Pillar Page | 0.9 | Main category hub |
| Make Pages | 0.8 | Secondary category hubs |
| Model Pages | 0.7 | Tertiary category pages (NEW) |
| Individual Vehicles | 0.6 | Leaf pages |
| Blog Posts | 0.5 | Informational content |
| Page Type | Change Frequency | Why |
|---|---|---|
| Model Pages | weekly | Vehicle inventory changes frequently |
| Make Pages | weekly | New models added regularly |
| Pillar Page | monthly | Stable overview content |
Model pages should be statically generated at build time (not server-rendered on each request).
Why SSG?
Astro Config:
// astro.config.mjs
export default defineConfig({
output: 'hybrid', // Default SSR, opt-in to SSG
adapter: vercel({
prerender: true // Enable pre-rendering
})
});
Page-Level Config:
---
// In [make]-[model]-stripping-for-spares.astro
export const prerender = true; // Force SSG for this route
export async function getStaticPaths() {
// Generate all model page paths at build time
// ...
}
---
For models with 50+ vehicles, implement pagination to avoid performance issues.
Implementation:
const VEHICLES_PER_PAGE = 30;
const totalPages = Math.ceil(vehicleCount / VEHICLES_PER_PAGE);
// URL: /bmw-3-series-stripping-for-spares/?page=2
const currentPage = Astro.url.searchParams.get('page') || 1;
const offset = (currentPage - 1) * VEHICLES_PER_PAGE;
const { data: vehicles } = await supabase
.from('vehicles')
.select('*')
.ilike('make', make)
.ilike('model', `%${model}%`)
.range(offset, offset + VEHICLES_PER_PAGE - 1);
Same Pattern Used In: src/pages/[make]-stripping-for-spares.astro (lines 43-45)
SEO Considerations:
rel="next" and rel="prev" tags?page=N to canonical URLConcern: Generating 50+ model pages at build time could slow down deploys.
Solutions:
Expected Build Time:
Acceptable: <2 minutes additional build time for Phase 1-3 combined.
Google Search Console (GSC):
Google Analytics (GA4):
Supabase Analytics:
| Metric | Target | Measurement |
|---|---|---|
| GSC Impressions | 500+ per model page | GSC → Performance → Filter by page |
| GSC CTR | 5-10% | GSC → Performance → Filter by page |
| Average Position | <10 (top 10) | GSC → Performance → Filter by page |
| Pageviews | 100+ per model page | GA4 → Pages and screens |
| Bounce Rate | <60% | GA4 → Pages and screens → Bounce rate |
| Conversion Rate | 2-5% | GA4 → Events → Quote submissions / Pageviews |
Month 1:
Month 2:
Month 3:
Ongoing:
src/pages/[make]-[model]-stripping-for-spares.astro---
import Layout from '../layouts/Layout.astro';
import { normalizeModel } from '../lib/normalizers/vehicleNormalizer';
import { slugify } from '../utils/slugs';
import Breadcrumb from '../components/Breadcrumb.astro';
import MakeModelLinks from '../components/MakeModelLinks.astro';
import VehicleCard from '../components/VehicleCard.astro';
import { getAdminSupabase } from '../lib/supabaseAdmin';
export async function getStaticPaths() {
const supabase = getAdminSupabase();
// Query database for all make/model combinations
const { data: vehicles } = await supabase
.from('vehicles')
.select('make, model')
.in('status', ['available', 'active'])
.not('featured_image', 'is', null);
// Group by make + normalized model
const modelCounts = new Map<string, { make: string, model: string, count: number }>();
vehicles.forEach(v => {
const normalized = normalizeModel(v.make, v.model);
const key = `${v.make.toLowerCase()}:${normalized.baseName.toLowerCase()}`;
if (!modelCounts.has(key)) {
modelCounts.set(key, { make: v.make, model: normalized.baseName, count: 0 });
}
modelCounts.get(key)!.count++;
});
// Filter models with 5+ vehicles
const MIN_VEHICLES = 5;
const paths = Array.from(modelCounts.values())
.filter(({ count }) => count >= MIN_VEHICLES)
.map(({ make, model }) => ({
params: {
make: slugify(make),
model: slugify(model)
},
props: {
makeName: make,
modelName: model
}
}));
return paths;
}
const { make, model } = Astro.params;
const { makeName, modelName } = Astro.props;
const supabase = getAdminSupabase();
// Get vehicles for this make + model
const { data: vehicles, count } = await supabase
.from('vehicles')
.select('*', { count: 'exact' })
.ilike('make', makeName)
.ilike('model', `%${modelName}%`)
.in('status', ['available', 'active'])
.not('featured_image', 'is', null)
.order('created_at', { ascending: false });
// Breadcrumb (4 levels)
const breadcrumbItems = [
{ name: 'Home', href: '/' },
{ name: 'Vehicles Stripping For Spares', href: '/vehicles-stripping-for-spares/' },
{ name: `${makeName} Spares`, href: `/${make}-stripping-for-spares/` },
{ name: `${makeName} ${modelName} Spares`, href: `/${make}-${model}-stripping-for-spares/` }
];
// BreadcrumbList Schema
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumbItems.map((item, index) => ({
"@type": "ListItem",
"position": index + 1,
"item": {
"@id": `https://www.enginefinder.co.za${item.href}`,
"name": item.name
}
}))
};
// ItemList Schema
const itemListSchema = {
"@context": "https://schema.org",
"@type": "ItemList",
"name": `${makeName} ${modelName} Vehicles Stripping For Spares`,
"description": `${count} ${makeName} ${modelName} vehicles available for stripping and spare parts in South Africa`,
"numberOfItems": count,
"itemListElement": vehicles.map((vehicle, index) => ({
"@type": "ListItem",
"position": index + 1,
"item": {
"@type": "Car",
"name": `${vehicle.year} ${makeName} ${modelName} Stripping For Spares`,
"url": `https://www.enginefinder.co.za/vehicles/${vehicle.slug}`,
"vehicleModelDate": vehicle.year,
"brand": { "@type": "Brand", "name": makeName },
"model": modelName,
"image": vehicle.featured_image,
"itemCondition": "https://schema.org/UsedCondition",
"offers": {
"@type": "Offer",
"availability": "https://schema.org/InStock"
}
}
}))
};
// Get related models (same make, different model)
const { data: relatedModels } = await supabase
.rpc('get_related_models', {
make_param: makeName,
exclude_model: modelName
})
.limit(5);
---
<Layout
title={`${makeName} ${modelName} Stripping for Spares | ${count} Vehicles`}
description={`Browse ${count} ${makeName} ${modelName} vehicles being stripped for spare parts in South Africa. Quality used parts from trusted scrapyards.`}
>
<!-- Schema Markup -->
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbSchema)} />
<script type="application/ld+json" set:html={JSON.stringify(itemListSchema)} />
<!-- Hero Section -->
<div class="hero bg-gradient-to-r from-blue-600 to-blue-800 text-white py-12">
<div class="container mx-auto px-4">
<Breadcrumb items={breadcrumbItems} theme="light" />
<h1 class="text-4xl font-bold mt-4">{makeName} {modelName} Stripping for Spares</h1>
<p class="text-xl mt-2">
{count} {makeName} {modelName} vehicles currently being stripped for parts
</p>
</div>
</div>
<!-- Vehicle Grid -->
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{vehicles.map(vehicle => (
<VehicleCard vehicle={vehicle} />
))}
</div>
</div>
<!-- Related Models (Horizontal Links) -->
{relatedModels && relatedModels.length > 0 && (
<section class="container mx-auto px-4 py-8 border-t">
<h2 class="text-2xl font-bold mb-4">Other Popular {makeName} Models Being Stripped</h2>
<ul class="grid grid-cols-1 md:grid-cols-3 gap-4">
{relatedModels.map(rm => (
<li>
<a
href={`/${slugify(rm.make)}-${slugify(rm.model)}-stripping-for-spares/`}
class="text-blue-600 hover:underline"
>
{makeName} {rm.model} Stripping
</a>
<span class="text-gray-500 ml-2">({rm.count} vehicles)</span>
</li>
))}
</ul>
</section>
)}
<!-- Cross-Links (Footer) -->
<MakeModelLinks make={makeName} model={modelName} />
</Layout>
Existing vehicles table already supports model pages:
| Column | Type | Used For |
|---|---|---|
make | text | Filtering by make |
model | text | Filtering by model (requires normalization) |
year | text | Display + filtering on model page |
transmission | text | Filtering on model page |
engine_size | text | Filtering on model page |
fuel_type | text | Filtering on model page |
featured_image | text | Display in grid (required) |
status | text | Filter active/available only |
created_at | timestamp | Ordering (newest first) |
slug | text | Vehicle detail page URL |
No migrations required. All necessary columns exist.
src/pages/stripping-for-spares/CLAUDE.md - Parent make pages documentationsrc/pages/[make]-stripping-for-spares.astro - Reference implementationsrc/pages/vehicles-stripping-for-spares/index.astro - Top-level stripping pagessrc/pages/admin/search-console/CLAUDE.md - GSC integration for demand analysissrc/lib/normalizers/modelSeoNormalizer.ts - Canonical model slug generationsrc/lib/normalizers/vehicleNormalizer.ts - Legacy normalizer (pre-SEO)src/components/internal-linking/SchemaMarkupGenerator.ts - Schema utilitiesCLAUDE.md → Custom Agents sectionDecision: No. If a make has <5 vehicles total, stick with make page only.
Rationale: Thin content risk outweighs SEO benefit. Focus on makes with sufficient inventory depth.
Decision: Start with high-volume (BMW, Mercedes, VW, Renault), expand as needed.
Rationale: Focus effort where impact is highest (80/20 rule). Add normalizers incrementally as lower-volume makes grow.
Decision: Use hyphens (-) for SEO best practice.
Example: /bmw-3-series-stripping-for-spares/ ✅ (not bmw_3_series ❌)
Rationale: Google treats hyphens as word separators, underscores as word joiners. Hyphens = better SEO.
Decision: Use flat URLs with breadcrumb schema for hierarchy signals.
Current (KEEP): /bmw-3-series-stripping-for-spares/ ✅
NOT recommended: /bmw-stripping-for-spares/3-series/ ❌
Rationale:
[make]-[model]-stripping-for-spares.astro is clean/cape-town/bmw-3-series-stripping-for-spares/), not nestedFor Geographic Pages (Future):
/{city}/{make}-{model}-stripping-for-spares/
/cape-town/bmw-3-series-stripping-for-spares/
City-prefix is preferred because:
Decision: Yes, reuse QuickQuoteForm component (prefill make + model).
Rationale:
Decision: Keep page live until 0 vehicles, then:
Rationale: Preserve SEO equity from existing rankings. Users can still submit quotes for wanted parts.
Decision: Only available (status IN ('available', 'active')).
Rationale: Sold vehicles mislead users. Better UX to show only in-stock inventory.
Goal: Create model-specific stripping pages that capture long-tail searches and improve user experience.
Approach: 3-level silo structure (Pillar → Make → Model) with soft internal linking.
Priority: High-volume models first (20+ vehicles), expand to medium/long-tail models.
Key Success Factors:
vehicleNormalizer.ts)Next Steps:
src/pages/[make]-[model]-stripping-for-spares.astroModel pages can now be prioritized and optimized using Google Search Console data. Use the seo-model-pages agent in coordination with the seo-redirect-manager agent for data-driven SEO.
Priority Score = (impressions × 0.4) + (clicks × 0.3) + (vehicle_count × 0.3)
| Priority | Criteria | Action |
|---|---|---|
| HIGH | >500 impressions OR >50 clicks AND >10 vehicles | Create/optimize page immediately |
| MEDIUM | >100 impressions OR >10 clicks AND >5 vehicles | Queue for review |
| LOW | <100 impressions AND <10 clicks | Skip or consolidate with make page |
| Source | Path | Data Available |
|---|---|---|
| GSC Dashboard | /admin/search-console/dashboard | Impressions, clicks, CTR, position |
| URL Mapping | /admin/search-console/url-mapping | Redirect suggestions with priority |
| Gap Analyzer | src/lib/seo-agent/gapAnalyzer.ts | Content opportunities, missing pages |
1. Authenticate via /admin/search-console/sites (OAuth2)
2. Fetch GSC metrics from /admin/search-console/dashboard
3. Cross-reference with vehicle counts from database
4. Calculate priority score for each make/model combination
5. Create pages for HIGH priority opportunities first
6. Monitor CTR improvements in GSC
Pages with high impressions but low CTR (<2%) are optimization targets:
Symptoms:
Solutions:
Every model page MUST include:
| Section | Required | Implementation | Status |
|---|---|---|---|
| Hero + Vehicle Count | ✅ Yes | Lines 391-427 | ✅ Done |
| Vehicle Grid (5+ vehicles) | ✅ Yes | Lines 541-614 | ✅ Done |
| Filter Bar | ✅ Yes | Lines 441-539 | ✅ Done |
| Related Models (3+) | ✅ Yes | Lines 335-367 | ✅ Done |
| Supplier Sidebar (1+) | ✅ Yes | Lines 320-333 | ✅ Done |
| SEO Content Block | ✅ Yes | Lines 729-782 | ✅ Done |
| FAQ Section (3+ questions) | ⏳ Recommended | Not implemented | TODO |
| Buying Tips | ⏳ Recommended | Not implemented | TODO |
To avoid template duplication penalties:
Unique Intro Paragraph: Each model page should have a unique intro mentioning:
Model-Specific Parts List: Include commonly requested parts:
BMW 3 Series: turbos, DME modules, N52/N54 engines, headlights
Mercedes C-Class: W204/W205 parts, OM651 engines, LED headlights
VW Golf: GTI parts, DSG gearboxes, TSI engines
Generation References: Mention model generations where applicable:
"Browse E90, F30, and G20 BMW 3 Series vehicles..."
Add to high-traffic model pages for rich snippets:
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What parts are available from BMW 3 Series vehicles being stripped?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Common parts include engines (N52, N54, B48), gearboxes, turbos, DME modules..."
}
},
{
"@type": "Question",
"name": "How do I get a quote for BMW 3 Series parts?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Use our quote form to describe the part you need..."
}
}
]
}
All model pages serve nationwide (South Africa). No city-specific variations exist.
Create city-specific pages ONLY when:
/{city}/{make}-{model}-stripping-for-spares/
/cape-town/bmw-3-series-stripping-for-spares/
/johannesburg/mercedes-benz-c-class-stripping-for-spares/
| City | Slug | Min Vehicles | Notes |
|---|---|---|---|
| Johannesburg | johannesburg | 10 | Largest market |
| Cape Town | cape-town | 10 | Second largest |
| Durban | durban | 8 | KZN hub |
| Pretoria | pretoria | 8 | Gauteng secondary |
| Port Elizabeth | port-elizabeth | 5 | Eastern Cape |
supplier.city or vehicle location fieldTo avoid city pages competing with national page:
hreflang tags (if applicable)Without normalization, the database creates duplicate pages:
/bmw-320i-stripping/ (1 vehicle)/bmw-330d-stripping/ (2 vehicles)/bmw-335i-stripping/ (1 vehicle)/bmw-3-series-stripping/ (24 vehicles) ← Should be the only page| Scenario | Rule | Example |
|---|---|---|
| Same model, different trim | Consolidate to series page | 320i, 330i, 340i → 3 Series |
| Same model, different gen | Single page with year filters | E90, F30, G20 → 3 Series |
| Performance variant | Consolidate unless >20 vehicles | M3 → 3 Series (unless high volume) |
| Coupe/Sedan variants | Consolidate | 3 Series Coupe → 3 Series |
| Electric variant | Separate page if >10 vehicles | i3 gets own page |
-- Find potential cannibalization (multiple variants per model)
SELECT
make,
normalized_model,
COUNT(DISTINCT model) as variant_count,
SUM(vehicle_count) as total_vehicles,
array_agg(DISTINCT model) as raw_variants
FROM (
SELECT
make,
model,
normalizeModelForSeo(make, model) as normalized_model,
COUNT(*) as vehicle_count
FROM vehicles
WHERE status IN ('available', 'active')
GROUP BY make, model
) subquery
GROUP BY make, normalized_model
HAVING COUNT(DISTINCT model) > 3
ORDER BY total_vehicles DESC;
modelSeoNormalizer.tsFile: src/lib/normalizers/modelSeoNormalizer.ts (1,082 lines)
| Make | Variants Covered | Canonical Models |
|---|---|---|
| BMW | 67 | 11 (3 Series, 5 Series, X5, etc.) |
| Mercedes-Benz | 71 | 18 (C-Class, E-Class, Vito, etc.) |
| Audi | 45 | 18 (A4, A3, Q5, etc.) |
| VW | 30 | 10 (Golf, Polo, Jetta, etc.) |
| Renault | 20 | 14 (Sandero, Clio, Kwid, etc.) |
Run periodically to find unmapped variants:
// Check for variants not in MODEL_MAPPINGS
const unmappedVariants = vehicles
.filter(v => {
const result = normalizeModelForSeo(v.make, v.model);
return result.slug === slugify(v.model); // Returns original = unmapped
})
.map(v => `${v.make}:${v.model}`);
console.log('Unmapped variants:', unmappedVariants);
CREATE TABLE seo_model_page_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action TEXT NOT NULL, -- 'created', 'updated', 'deleted', 'redirected'
page_slug TEXT NOT NULL,
make TEXT,
model TEXT,
old_state JSONB, -- Previous page config (for rollback)
new_state JSONB, -- New page config
reason TEXT, -- "Thin content", "GSC demand", "Manual request"
gsc_data JSONB, -- Snapshot of GSC metrics at time of change
vehicle_count INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by TEXT DEFAULT 'seo-model-pages-agent'
);
-- Index for recent changes lookup
CREATE INDEX idx_seo_changes_created ON seo_model_page_changes(created_at DESC);
SELECT * FROM seo_model_page_changes
WHERE created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC;
📊 SEO Model Pages Audit - Last 30 Days
Pages Created: 12
Pages Updated: 5
Pages Redirected: 3
Pages Deleted: 0
Top Performers (GSC):
1. /bmw-3-series-stripping-for-spares/ - +45% CTR, 1,200 clicks
2. /mercedes-benz-c-class-stripping-for-spares/ - +32% CTR, 890 clicks
Underperformers (need attention):
1. /chery-qq-stripping-for-spares/ - 0 clicks, position 45
→ Recommendation: Redirect to /chery-stripping-for-spares/
Rollback Candidates:
- None (all changes showing positive impact)
The make pages (src/pages/[make]-stripping-for-spares.astro) have been enhanced with two new sections to improve user engagement, lead generation, and internal SEO linking.
Location: Lines 1180-1250
Previous State: Plain text list of part categories with inline links to engines page
New Design:
wa.me/27723375272)Parts Included (12 total):
| Part | Image | Category |
|---|---|---|
| Complete Engine | complete-engine.webp | Engine |
| Gearbox | gearbox.webp | Transmission |
| Turbocharger | turbocharger.webp | Engine |
| Cylinder Head | cylinder-head.webp | Engine |
| Alternator | alternator.webp | Electrical |
| Starter Motor | starter-motor.webp | Electrical |
| Radiator | radiator.webp | Cooling |
| ECU / Computer | ecu-computer.webp | Electrical |
| Fuel Pump | fuel-pump.webp | Fuel |
| Power Steering | power-steering-pump.webp | Steering |
| Air Conditioning | ac-compressor.webp | Climate |
| Suspension | suspension.webp | Suspension |
Image Location: /public/images/parts/ (50 WebP files available)
WhatsApp Number: 27723375272 (Engine Finder automated flow)
Code Pattern:
<a
href="https://wa.me/27723375272"
target="_blank"
rel="noopener noreferrer"
class="group relative bg-white rounded-xl border hover:shadow-lg hover:border-[#E41F29] transition-all duration-300 transform hover:-translate-y-1 active:scale-95"
>
<div class="aspect-square bg-gray-50 relative overflow-hidden">
<img src={`/images/parts/${part.image}`} class="w-full h-full object-contain p-3 group-hover:scale-110 transition-transform duration-300" />
<!-- Category badge & WhatsApp indicator -->
</div>
<div class="p-3 text-center border-t">
<span class="text-sm font-medium">{part.name}</span>
</div>
</a>
Location: Lines 1266-1290
Purpose: Contextual internal links to model pages for SEO (soft silo)
Why This Approach:
Implementation:
<!-- Query models with 5+ vehicles -->
const MIN_VEHICLES_FOR_MODEL_PAGE = 5;
const { data: allModelsData } = await supabase
.from('vehicles')
.select('model, suppliers!inner(status)')
.eq('suppliers.status', 'active')
.ilike('make', makeNameForDB)
.not('featured_image', 'is', null);
// Group by normalized SEO slug
const seoModelCounts = new Map<string, number>();
allModelsData?.forEach(v => {
const seoModel = getCanonicalModelObject(makeName, v.model);
seoModelCounts.set(seoModel.slug, (seoModelCounts.get(seoModel.slug) || 0) + 1);
});
// Top 12 models sorted by count
const browseModels = Array.from(seoModelCounts.entries())
.filter(([_, count]) => count >= MIN_VEHICLES_FOR_MODEL_PAGE)
.sort((a, b) => b[1] - a[1])
.slice(0, 12);
Display:
/{make}-{model}-stripping-for-spares/ pagesAnchor Text Strategy:
Example Output (BMW):
Browse BMW Models
┌─────────────────┬──────────────────┐
│ 3 Series [49] │ 1 Series [13] │
├─────────────────┼──────────────────┤
│ X5 [8] │ 5 Series [7] │
└─────────────────┴──────────────────┘
Before Enhancement:
After Enhancement:
Key Files Changed:
src/pages/[make]-stripping-for-spares.astro - Added Browse Models query + sectionsrc/lib/normalizers/modelSeoNormalizer.ts - Used for canonical slug generationProblem: Model pages were showing thin content errors for valid models (e.g., BMW 1 Series with 13 vehicles).
Root Cause: The database query applied .range(0, 29) pagination BEFORE filtering by model. This meant:
Why 3 Series Worked: With 49 vehicles, more likely to appear in first 30 newest BMW vehicles. Why 1 Series Failed: With 13 vehicles, may not be in newest 30 BMW vehicles.
Solution: Remove .range() from initial query, filter all vehicles first, then apply pagination with .slice().
Before (Lines 140-186):
const { data: vehicles } = await supabase
.from('vehicles')
.select('*')
.ilike('make', makeNameForDB)
.eq('suppliers.status', 'active')
.not('featured_image', 'is', null)
.range(offset, offset + VEHICLES_PER_PAGE - 1); // ❌ Pagination BEFORE model filter
After:
const { data: allVehicles } = await supabase
.from('vehicles')
.select('*')
.ilike('make', makeNameForDB)
.eq('suppliers.status', 'active')
.not('featured_image', 'is', null); // ✅ No pagination - get all
// Filter by model using SEO slug comparison
const allMatchingVehicles = allVehicles?.filter(v => {
const vehicleSlug = getCanonicalModelObject(v.make, v.model).slug;
const expectedSlug = `${makeSlug}-${modelSlug}`;
return vehicleSlug === expectedSlug;
}) || [];
// Apply pagination AFTER filtering
const vehicles = allMatchingVehicles.slice(offset, offset + VEHICLES_PER_PAGE);
Key Insight: getCanonicalModelObject() returns full slug (e.g., “bmw-3-series”), not just model portion.
Problem: Sidebar had long single-column list requiring excessive scrolling.
Solution: Compact 2-column grid layout with WhatsApp hover overlay.
New Design Features:
grid grid-cols-2 gap-2 for compact displayCategories (5 total):
| Category | Parts |
|---|---|
| Engine & Mechanical | Complete Engine, Cylinder Head, Turbocharger, Gearbox |
| Electrical | Alternator, Starter Motor, ECU, Wiring Loom |
| Body & Exterior | Bumpers, Doors, Headlights, Tail Lights |
| Cooling & Drivetrain | Radiator, Differential, Steering Rack, Alloy Wheels |
| Interior | Dashboard, Seats |
Code Pattern:
<div class="grid grid-cols-2 gap-2 mt-2">
<a href={whatsappUrl} class="group relative bg-gray-50 rounded-lg p-2 text-center hover:bg-green-50 transition-colors">
<!-- WhatsApp overlay -->
<div class="absolute inset-0 bg-green-500 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<svg class="w-8 h-8 text-white"><!-- WhatsApp icon --></svg>
</div>
<span class="relative z-10 text-sm group-hover:text-white transition-colors">Part Name</span>
</a>
</div>
WhatsApp Message Format:
Hi, I'm looking for a Complete Engine for a {makeName} {modelName}. Do you have any available?
File Changed: src/pages/[make]-[model]-stripping-for-spares.astro (lines 640-900)
File: .claude/agents/seo-model-pages.md
Use for:
File: .claude/agents/seo-redirect-manager.md
Use for:
1. SEO Model Pages: Identify high-volume models from DB
2. → SEO Redirect Manager: Fetch GSC data for demand validation
3. SEO Model Pages: Calculate priority score (DB + GSC)
4. SEO Model Pages: Create pages for HIGH priority opportunities
5. → SEO Redirect Manager: Create redirects from old variant URLs
6. Monitor GSC for 30 days
7. Iterate based on CTR/position data
Analyze models with GSC data:
Analyze BMW models in the database. Cross-reference with GSC impressions.
Show vehicle counts, GSC metrics, and recommend which models need dedicated pages.
Detect cannibalization:
Scan all stripping-for-spares pages for potential cannibalization.
Identify models with multiple URL variants competing for same keywords.
Recommend consolidation strategy with 301 redirects.
Create optimized page:
Create a model page for Mercedes-Benz C-Class with:
- Proper breadcrumb schema (4 levels)
- Unique intro mentioning W204/W205 parts
- FAQ schema for rich snippets
- Internal links to related models and engines page
Before modifying SEO model pages:
subagent_type: "seo-model-pages" for model page worksubagent_type: "seo-redirect-manager" for redirect worksrc/pages/seo-model-pages/CLAUDE.mdExample agent prompts:
Analyze BMW models in the database. Cross-reference with GSC impressions.
Show vehicle counts, GSC metrics, and recommend which models need dedicated pages.
Scan all stripping-for-spares pages for potential cannibalization.
Identify models with multiple URL variants competing for same keywords.
Recommend consolidation strategy with 301 redirects.
Create a model page for Mercedes-Benz C-Class with proper breadcrumb schema,
unique intro mentioning W204/W205 parts, FAQ schema, and internal links.