Engineering Diary, Day 24: SEO/GEO Push to 10K — 2,433 New URLs Across 4 Waves and a Hub Linking Restructure
The Setup: 8,000 Indexed, Target 10,000
The day started with a clear directive: get Google-indexed pages from 8,000 back up to 10,000+. We had been at 9,200 a few weeks earlier — the drop wasn't a penalty, just discovery lag and a few technical issues piling up.
Diagnosis took 30 minutes across two GSC export bundles and a sitemap audit. Root causes:
- 6,336 pages "Discovered — currently not indexed" (76% of un-indexed). Natural Google crawl backlog. Auto-resolves as site quality stabilizes.
- 726 pages with hreflang/canonical mismatches in validation.
- 203 pages wrongly blocked by robots.txt — under validation for removal.
- 40 pages with duplicate locale paths (
/ja/ja/,/pt/pt/...) — middleware was already 301-redirecting them. - 1 single, fatal omission: the Industry Insights module (103 insights × 11 locales ≈ 1,133 pages) had been deployed two weeks ago but never added to sitemap.ts. The pages existed; Google just didn't know they existed.
We turned the diagnosis into a four-wave plan and shipped all of it the same day.
Wave 1 — Sitemap Fix + GEO Sync (+121 URLs, ~1,331 hreflang candidates)
The single highest-ROI action of the day was a 30-line addition to sitemap.ts: a loop over getAllInsightSlugs() + the type/daily/weekly aggregations. 121 new URLs in one commit.
For Generative Engine Optimization (GEO) — making content discoverable to ChatGPT, Claude, Perplexity — we extended the generate-llms-full.ts build script to include a full Industry Insights index table: 103 entries with date, type, confidence, entities, and URL. The regenerated llms-full.txt is now 1,523 lines, 127 KB.
IndexNow already had the industry-insights category from a prior commit. We pushed all 121 URLs to Bing/Yandex/Naver/Seznam within minutes of deploy.
Wave 2A — Operator × Aircraft Pair Pages (+1,798)
Then a question: we have 54 operators and 161 aircraft. Could we generate meaningful per-pair pages without making thin programmatic content? The full Cartesian is 8,694 — Google would penalize most of it.
The answer was a two-tier match in operator-aircraft-match.ts:
- Tier 1 — "Operates": operator's
fleetTypesarray fuzzy-matches the aircraft name. Strong, factual signal. 406 pages, no cap. - Tier 2 — "Compatible": aircraft category overlaps with the operator's existing fleet category. Capped at
max(12, min(30, opCategories.size × 12))per operator, scored by manufacturer match + detail-page richness. 1,392 pages.
Total: 1,798 high-quality pair pages at /insights/operators/[slug]/aircraft/[aircraftSlug]. Each has a 5-level breadcrumb, FAQPage rich result, tier-colored badge (green for Operates, ice-blue for Compatible), pricing breakdown using operator-type rate multipliers, and aircraft spec table.
We deliberately didn't reuse EntityPageShell — that component was designed for single-entity pages with 3-level breadcrumbs. Pair pages are inherently two-entity, so we composed sub-components (MetricCard, InsightSection, CoBrandingBadge) directly. Less abstraction, more honest representation of the data.
Wave 2B — Empty Leg × Category Cost Pages (+223)
The base /empty-legs/[slug] page shows aggregate pricing across all commonAircraft for a route. But the search intent "[city pair] empty leg in [light/midsize/heavy/ultra] jet — cost?" wasn't directly answered.
We built empty-leg-cost-match.ts that takes each of 62 routes × 4 categories, range-filters out impossible combinations (a light jet can't fly a 3,300nm Singapore-Sydney empty leg), and computes:
- Standard charter cost = category hourly rate × flight time
- Empty leg cost = standard × 30–55% discount band
- Per-passenger cost using typical category capacity (light=6, mid=8, heavy=12, ultra=14)
- Top 3 typical aircraft of that category that can actually fly the route
- A "fit note" that explicitly tells the user when a category is overkill (heavy jet on a 1,000nm hop) or undersized — no false confidence
Result: 223 pages at /empty-legs/[slug]/[category] where category ∈ {light-jet, midsize-jet, heavy-jet, ultra-long-range}. URL slugs are deliberately verbose because "light jet empty leg cost" is a real long-tail query and it should appear in the URL itself.
Wave 2C — Airport + Aircraft FAQ Entity Pages (+249)
Search Console data showed lots of unanswered intent on questions like "what FBOs are at Teterboro" or "how much does a Phenom 300 charter cost". We had the data — runway lengths, FBO counts, hourly rates, range — but no dedicated FAQ pages with FAQPage JSON-LD.
Two new generators:
- airport-faq.ts — 7 Q&As per airport: ICAO/IATA codes, runway length and jet category access (with a runway-to-category heuristic), FBO list with services summary, customs availability, 24/7 operations, popular routes, nearby attractions. All from
FeaturedAirport+getFBOsByAirportdata. - aircraft-faq.ts — 7 Q&As per aircraft: hourly rate × 5h trip cost, range with example routes by bracket, cabin specs, which operators fly it (reusing Wave 2A's
getAircraftOperatorMatches), similar aircraft in same category, manufacturer + speed, booking flow.
Wave 2A unlocked Wave 2C: the aircraft FAQ's "Which operators fly the Global 7500?" question is answered by real data from the operator-aircraft match, so the answer reads "NetJets, Flexjet, VistaJet, XO, and Global Jet Capital, plus 12 more operators" — not a fabricated list.
Wave 3 — GEO Moat: Person, NewsArticle, SpecialOffer (+12 URLs, schema lift on 285)
The page count of Wave 3 is small (12 new URLs). The schema lift is large.
Six unique authors emerged from grepping blog/posts*.ts: VOLO Editorial, CTO, Engineering, Aviation, Strategy, Leadership. Not 84 individual personas — six team entities, which is honest and avoids fake bios.
For each:
/about/authors/[slug]— Person schema bio page withjobTitle,worksFor,knowsAbout,sameAs. Stable@idURLs let other schemas reference the Person./blog/by/[author]— CollectionPage + ItemList schema archive with all posts by this author.
The blog post pages got a quiet but consequential upgrade: the inline articleJsonLd object was replaced with a <NewsArticleJsonLd> component. The @type changed from ["Article", "BlogPosting"] to ["NewsArticle", "BlogPosting"] — eligibility for Google News Top Stories. The author resolved to a real Person via getAuthorByName(post.author), with author.@id linking to /about/authors/[slug]. This is the E-E-A-T signal the search algorithms actually look for.
Empty leg pages got SpecialOfferJsonLd: an Offer entity with priceSpecification, LimitedAvailability, and a standard-charter reference price embedded in eligibleTransactionVolume (schema.org's Offer type doesn't have a first-class "% off" field). 62 parent pages plus 223 category sub-pages = 285 newly Offer-eligible URLs.
Internal Linking — From 4 Hubs to ~2,400 Sub-pages
Adding URLs to a sitemap doesn't automatically create link equity. The 2,400 new sub-pages had only their breadcrumbs as incoming links. So the next pass: every hub page needs to surface its sub-pages.
| Hub | Surfaces | Format |
|---|---|---|
/insights/operators/[slug] | Up to 8 "Aircraft Operated" cards | 3-col grid, tier-colored badges |
/empty-legs/[slug] | 1–4 "Browse by Aircraft Category" cards | 4-col grid, before FAQs |
/fleet/[slug] | Up to 6 "Operated By" cards + FAQ link | 2-col grid + accent card |
/airports/[slug] | "See all FAQs" link | Inline, end of FAQ section |
~2,090 internal links from authoritative hubs to programmatic sub-pages. PageRank flows where the data tells it to flow.
The Slug-System Debt
The internal linking work surfaced an old design issue. /insights/operators/[slug] uses slugs extracted from monthly AviGo flight reports (e.g. "netjets-aviation"). /insights/operators/[slug]/aircraft/[aircraftSlug] uses slugs from the operators.ts master list (e.g. "netjets"). Same URL family — completely different slug systems.
The fix is a small bridge in operator-aircraft-match.ts:
export function resolveOperatorSlugFromName(operatorName: string): string | null {
const target = operatorName.toLowerCase().trim();
const operators = getAllOperators();
const exact = operators.find((o) => o.name.toLowerCase() === target);
if (exact) return exact.slug;
const partial = operators.find(
(o) => target.includes(o.name.toLowerCase()) || o.name.toLowerCase().includes(target),
);
return partial?.slug ?? null;
}
Entity page passes its operator name through this function to get the master slug for outgoing pair-page links. Both slug systems still exist — but they're now bridged at the linking boundary instead of pretending to be the same.
Polish — Two Real Bugs Found While Verifying
- Fleet detail page has two render branches. 184 of 199 aircraft are catalog-only and early-return through
<CatalogDetailPage>. The first version of the "Operated By" section only landed in the detailed-aircraft branch — invisible for the majority. Fix: render the new sections in both branches. - IndexNow had no
operator-entitycategory. The 34/insights/operators/[slug]hub pages weren't covered by any category in the IndexNow API, only by manualurls: [...]pushes. Now they have their own case and are included in"all"— the weekly cron picks them up automatically.
Final Numbers
| Metric | Before | After |
|---|---|---|
| Sitemap URLs (English) | 1,750 | 4,195 |
| Hreflang candidates (× 11 locales) | ~19,250 | ~46,000 |
| JSON-LD types in use | 17 | 21 (+ Person, NewsArticle, SpecialOffer, IndustryInsight) |
| IndexNow categories | 13 | 19 |
Programmatic SEO page types under [locale]/ | 0 new | 6 new |
| Hub-to-sub-page internal links | 0 | ~2,090 |
| Tests | 261/261 | 261/261 |
The push from 8,000 to 10,000 indexed pages doesn't happen overnight — Google takes 6–8 weeks to digest sitemap and crawl the long tail. But everything we control is now lined up: pages exist, sitemap declares them, IndexNow pings four engines, hub pages link down to them, and the schemas signal authority.
What I'd Do Differently
- Catch the sitemap omission earlier. The Industry Insights module shipped two weeks ago without its sitemap entries. Solution: add a sitemap-coverage assertion to the test suite — every route under
app/[locale]/withgenerateStaticParamsshould appear insitemap.ts. This would have failed CI on the original Industry Insights commit. - Treat slug systems as type-safe. Two slug systems for the same URL family shouldn't be "fixed by string match" — they should be
OperatorMasterSlugandOperatorEntitySlugbranded types so the compiler refuses to mix them. - Build the IndexNow assertion alongside the sitemap entry. Three of today's commits were "I added a route, now I have to remember to also add the IndexNow case." Should be one TypeScript step, not three.
The Day 25 entry will probably be about closing those three loops. For now: 4 commits on origin/main, Vercel deploy green, IndexNow submitting, and we wait for Google to do its slow, deliberate work.
هل أنت مستعد للطيران؟ احصل على عرض سعر تأجير مخصص في ثوانٍ.
ابقَ على اطلاع
عروض الأرجل الفارغة، مسارات جديدة، ورؤى الطيران — مباشرة إلى بريدك.