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.
비행 준비가 되셨나요? 몇 초 안에 맞춤 전세 견적을 받으세요.
최신 소식 받기
엠프티 레그 특가, 신규 노선, 항공 인사이트를 이메일로 받아보세요.