Engineering Diary, Day 24: SEO/GEO Push to 10K — 2,433 New URLs Across 4 Waves and a Hub Linking Restructure
We started the day with 8,000 Google-indexed pages — down from a peak of 9,200 — and a CEO directive: get to 10,000+. The diagnosis turned up a single missed sitemap inclusion (Industry Insights had pages but wasn't in sitemap.ts), a category mismatch in IndexNow, and three high-ROI programmatic SEO seams. We spent the day shipping four coordinated waves: sitemap fix + GEO sync, three new programmatic page types (operator × aircraft, empty-leg × category, airport + aircraft FAQ), schema upgrades for E-E-A-T (Person, NewsArticle, SpecialOffer), and internal linking from four hub pages down to ~2,400 sub-pages. Net: +2,433 sitemap URLs, ~26K hreflang candidates, 261/261 tests green.
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.
Frequently Asked Questions
Why was the indexed page count down to 8,000 from a peak of 9,200?+
Mostly discovery lag and hreflang misconfiguration, not a penalty. Search Console showed 6,336 pages 'Discovered, currently not indexed' (Google's natural crawl backlog), 726 with hreflang/canonical mismatches under validation, 203 wrongly blocked by robots.txt, and ~40 duplicate locale paths (/ja/ja/...) being 301-redirected by middleware. The single biggest miss was that the Industry Insights module (103 insights × 11 locales = ~1,133 pages) had been deployed but never added to sitemap.ts.
What are the new programmatic SEO page types added today?+
Three. (1) Operator × Aircraft pair pages at /insights/operators/[slug]/aircraft/[aircraftSlug] — 1,798 pages with two tiers ('operates' confirmed via operator.fleetTypes, 'compatible' capped at top 30 per operator). (2) Empty Leg × Category cost pages at /empty-legs/[slug]/[category] — 223 pages (light-jet/midsize-jet/heavy-jet/ultra-long-range), range-filtered so we don't generate light-jet pages for 3,300nm routes. (3) Airport + Aircraft FAQ entity pages at /airports/[slug]/faq and /fleet/[slug]/faq — 249 pages with 7 data-driven Q&As each, generated from existing entity data plus Wave 2A operator matches.
What does the GEO (Generative Engine Optimization) work add?+
Three schema upgrades. (1) Person schema on 6 author bio pages establishes E-E-A-T entities; each NewsArticle on a blog post now points its author.@id to the corresponding /about/authors/[slug] URL — search engines can stitch the author identity across articles. (2) NewsArticle (replacing generic Article) on all 84 blog posts unlocks Top Stories carousel eligibility. (3) SpecialOffer on all 62 empty-leg parent pages plus the 223 category sub-pages publishes structured pricing with priceSpecification and a standard-charter reference price — eligible for Google's offer rich result.
How do hub pages link to the new sub-pages?+
Each of four hub page types now surfaces relevant sub-pages: (1) /insights/operators/[slug] shows up to 8 'Aircraft Operated' cards linking to pair pages; (2) /empty-legs/[slug] shows 1–4 'Browse by Aircraft Category' cards; (3) /fleet/[slug] shows up to 6 'Operated By' cards plus a FAQ link card; (4) /airports/[slug] adds a 'See all FAQs' link inside the existing FAQ section. Total: ~2,090 hub-to-sub-page internal links — concentrated PageRank flow, not just sitemap presence.
What was the slug-system bug that surfaced during internal linking?+
The /insights/operators/[slug] entity hub uses data-extracted slugs (e.g. 'netjets-aviation' from monthly flight reports), while /insights/operators/[slug]/aircraft/[aircraftSlug] pair pages use the operators.ts master list slugs (e.g. 'netjets'). They look like the same URL family but they're not. We bridged the two with resolveOperatorSlugFromName() — a case-insensitive name match that lets the entity page resolve to the master slug for outgoing pair-page links. A reminder that 'one URL pattern, two slug systems' is its own kind of debt.
Ready to fly? Get a personalized charter quote in seconds.
Stay Informed
Empty leg deals, new routes, and aviation insights — delivered to your inbox.