Engineering Diary, Day 25: 8 PRs, 1 Production Outage, and the URL Fragment Deprecation Nobody Told Us About
Day 24 ended with three predictions for what Day 25 would be. Day 25 turned out to be none of them. Instead the week was about the agent platform growing up — 14-tool Claude Concierge surfaced everywhere, 218 aircraft / 11 locales / 13 MCP tools synced across every public .well-known/* discovery file, and 14 new AI crawler User-Agents explicitly allowed in robots.txt. Then mid-week the CEO sent a screenshot of Google Search Console showing 5 of 6 videos failing to index. Twelve minutes of curl-on-prod later, the cause was clear: Google deprecated URL fragments for video sitemaps in 2023, and all 8 of our self-hosted shorts were pointing /video-centre#anchor — collapsing to a single page Google could only index once. The fix took 2 hours: dedicated /video-centre/[slug] landing pages, sitemap rewrite, IndexNow category. The hotfix to the fix took 90 minutes — a DYNAMIC_SERVER_USAGE crash that only surfaces when generateStaticParams omits the locale segment in a nested next-intl layout. 8 PRs merged, 287/287 tests green throughout, one 90-minute outage, no data loss.
Eight PRs in Five Days — A Week's Index
Day 24 ended with three predictions for what Day 25 would be. Day 25 turned out to be none of them. Here's what actually happened, in merge order:
- PR #20 (2026-05-18) — i18n Waves 9.1 through 9.15. 11-locale chrome on ~25,500 page render combinations. The last of a 26-commit overnight push that closed the "Japanese viewers still see English headings" gap CEO had flagged.
- PR #21 (2026-05-18) — Two paired video landings (LA WC2026 + Top 5 jets) plus a complete rewrite of
/for-agents. The agent platform's coming-of-age: scale stats strip (13 MCP / 14 Concierge / 16 CLI / 30 REST / 26K+ URLs), Six Ways to Connect card grid, Live Data Layer section, Built for LLM Discovery section. - PR #22 (2026-05-19) — Site-wide staleness sweep. Self-review of PR #21 surfaced that every public discovery surface still said "10 tools" (Concierge count) when the actual count had been 14 for weeks. Discovery API, OpenAPI, llms.txt, llms-full.txt, press kit, homepage FAQ JSON-LD — 18 different locations, all corrected in one pass. Created an "agent stack truth" memo: when any count changes, here are the 18 places to update.
- PR #23 (2026-05-19) —
.well-known/*agent-discovery file sync.mcp.jsontool count 10 → 13 + new related_surfaces block.ai-plugin.jsondescription_for_model rewritten.agents.jsonprotocols list expanded from 3 to 8. Plus 10 new AI crawlers explicitly allowed in robots.txt (Claude-Web, OAI-SearchBot, Applebot-Extended, meta-externalagent, Bytespider, Diffbot, Amazonbot, DuckAssistBot, YouBot, GoogleOther). Plus the WC2026 cross-link triangle (Toronto + Mexico City + LA blogs). - PR #24 (2026-05-19) — Post-merge prod verification caught that
/fleet/falcon-10xwas a soft-404. The Top 5 jets blog (PR #21) had linked to it, but the Falcon 10X had never been added toaircraft-catalog.tsbecause its EIS slipped from 2025 to 2027. Catalog entry added; broken link healed in 30 minutes. - PR #25 (2026-05-21) — Two new paired social launches: WC2026 host cities master guide + 2025 business aviation by country TOP 5. Each with its own 9:16 short. This is where the trouble started, although nobody knew it yet.
- PR #26 (2026-05-21) — Diagnostic-driven SEO sweep. Five strands: Speakable schema on all NewsArticle + IndustryInsight schemas; per-post dynamic OG images (was using ONE Pexels stock photo for all 95 posts in the schema's image field); wordCount on NewsArticle; RSS feed video enclosures; Organization JSON-LD address fix (New York → Singapore).
- PR #27 (2026-05-21) — Per-video landing pages. CEO surfaced GSC report: 5 of 6 videos showing "No video indexed". Diagnosis below. Shipped with a bug.
- PR #28 (2026-05-22) — Hotfix for PR #27. 90 minutes from prod failure to fix landed.
The Video Indexing Bug — Twelve Minutes of curl-on-Prod
The CEO sent a screenshot of Google Search Console mid-afternoon. Video indexing report: 1 video indexed, 5 not indexed, 1 reason. The 1 reason wasn't visible in the screenshot.
First step was a curl on production to read the actual video sitemap:
$ curl -s https://www.flyvolo.ai/video-sitemap.xml | grep '<loc>'
<loc>https://www.flyvolo.ai/video-centre#falcon-7x-private-jet-short</loc>
<loc>https://www.flyvolo.ai/video-centre#gulfstream-g550-cabin-short</loc>
<loc>https://www.flyvolo.ai/video-centre#world-cup-2026-canada-by-private-jet</loc>
<loc>https://www.flyvolo.ai/video-centre#world-cup-2026-usa-mexico-by-private-jet</loc>
...
There it was. Every single video entry pointed to the same base URL with a different URL fragment. Google deprecated this in 2023: the fragment is stripped before URL matching for video sitemaps, so all 8 entries collapse to /video-centre and only one VideoObject schema can pair with that URL. The reported GSC error is "Video is not the main content of the page" — exactly because Google sees one URL with eight competing videos and concludes none of them is the primary content.
Reproduced in 12 minutes from CEO's screenshot to confirmed root cause. The fix was clear: each self-hosted short needs its own dedicated URL. Two and a half hours of work to build the dynamic route, generate the VideoObject JSON-LD without a URL fragment, transcript display, related-videos block, cross-link to the paired blog post. /video-sitemap.xml rewritten to point <loc> at /video-centre/[slug]. IndexNow gained a "videos" category submitting the 8 new URLs to Bing/Yandex.
The Hotfix — Ninety Minutes
PR #27 merged, Vercel auto-deployed, sitemap on prod showed the new URLs. Sanity curl:
$ curl -s -o /dev/null -w "HTTP %{http_code}
" \
https://www.flyvolo.ai/video-centre/top-5-longest-range-business-jets
HTTP 500
Every single per-video URL returned 500. Local build succeeded, prerendered the routes as SSG (● in next build output), tests passed. So something runtime-only.
Replicated locally with pnpm exec next build && next start:
⨯ [Error: An error occurred in the Server Components render]
digest: 'DYNAMIC_SERVER_USAGE'
DYNAMIC_SERVER_USAGE is the Next.js error when something tries to use a request-scoped API (cookies, headers, dynamic params) inside a page that's supposed to be statically rendered. My page didn't reference any of those. So why?
It took fifteen minutes to spot the actual problem. My generateStaticParams returned [{slug: 'x'}, {slug: 'y'}, ...] — just the slug, no locale. The route is /[locale]/video-centre/[slug]. Next.js prerendered exactly one variant per slug (whichever locale it picked first, which was en), and treated the locale as dynamic for everything else.
At request time, the [locale] layout runs getMessages() from next-intl/server, which is request-scoped → dynamic API used in static page → DYNAMIC_SERVER_USAGE → 500.
The fix is one line of code, four lines if you count the comment:
export function generateStaticParams() {
const shorts = getAllVideos().filter((v) => v.format === "short" && Boolean(v.videoUrl));
return shorts.flatMap((v) => locales.map((locale) => ({ locale, slug: v.slug })));
}
export const dynamicParams = false;
The pattern is identical to /blog/[slug]. We just forgot to copy it when we built the new route. Cost: 5 minutes of legitimate prod 500 on 5 video URLs (most of which weren't generating traffic yet). Lesson: when creating a new dynamic route under [locale], copy the generateStaticParams shape from a working sibling first.
The Singapore Address Bug
The smallest and most consequential bug of the week was found by accident. While doing the Day 25 SEO sweep, I grep'd every PostalAddress in the codebase and found this in OrganizationJsonLd:
address: {
"@type": "PostalAddress",
addressLocality: "New York",
addressRegion: "NY",
addressCountry: "US",
},
The Organization schema is rendered on every page from the root layout. Every public-facing surface — press kit, footer, CLAUDE.md, the JETBAY parent organization page — says Singapore. The JSON-LD said New York. Quietly, on every page.
This is the exact kind of inconsistency that poisons LLM training data. When Claude or GPT crawls our content for training, it sees two different claims about our HQ location. The model loses confidence in both. We become a less-authoritative source on our own facts.
Fixed. Plus expanded the same schema with alternateName: ["VOLO", "flyvolo.ai", "VOLO AI"], added Twitter/X to sameAs, bumped availableLanguage from 4 to 11 (matching real coverage), added a second ContactPoint for sales (charter@flyvolo.ai), set areaServed explicitly to Worldwide.
The i18n Coverage Gap We Haven't Fixed Yet
CEO asked an excellent diagnostic question this week: "We have 11 locales — but on many pages Japanese visitors still see English content. How bad is it?"
The answer is more nuanced than my first audit suggested. Correction notice: my initial audit (still preserved in the engineering Slack thread, retained here for honesty) overstated the gap on destinations and routes by reading the wrong field names. A second-pass audit using the actual MultilingualText field structure revealed:
| Content type | Total | EN + ZH coverage | Other 8 locales (ja/ko/fr/es/de/pt/ru/ar) | zh-Hant |
|---|---|---|---|---|
| Blog posts | 98 | 98 / 98 (after PR #33) | 0 / 98 | 0 / 98 |
| Destinations | 100 | 100 / 100 ✓ | 0 / 100 | 0 / 100 |
| Routes | 224 | 224 / 224 ✓ | 224 / 224 ✓ | 0 / 224 |
| Industry insights | 103 | UI ✓ + body | UI ✓; body ~90% EN | UI ✓; body EN |
| Monthly reports | 8 | 11-locale via monthly-report-i18n | ✓ | ✓ |
The corrected picture is much better than the first audit suggested. The remaining real gaps:
- Blog posts: en + zh complete, but 0 of 98 have any of the other 9 locales (ja, ko, fr, es, de, pt, ru, ar, zh-Hant).
- Destinations: en + zh complete, but 0 of 100 have any of the other 9 locales.
- Routes: en + zh + 8 other locales complete (224 routes × 9 locales already in place from a prior pass we'd forgotten about). Missing only zh-Hant for all 224 — and zh-Hant is mechanically derivable from zh via OpenCC simplified-to-traditional conversion, no real translation work needed.
So a French visitor on /fr/destinations/cannes: UI in French ✓, city name "Cannes" ✓, description still 100% English — but a French visitor on /fr/routes/new-york-aspen sees the route description in French. Routes were translated to 8 locales in an earlier pass that I'd lost track of.
What this changes for the work plan: the destinations × 9 locales work is real (~100 × 9 = 900 translation units, ~$15 via Sonnet 4.6 with caching). Routes need only the trivial zh-Hant pass (deterministic, no API call needed — OpenCC s2t converter handles it offline). Blog posts × 9 locales is the largest remaining piece. Lesson: when auditing a complex data shape, always read the actual files instead of trusting a regex audit. I shipped this correction on Day 26 after rerunning the audit with the correct field names.
The translator script (scripts/translate-blog-content-zh.ts) is committed and ready; running the full Phase 2 expansion is queued.
What Day 24's Predictions Got Wrong
Day 24 predicted three things for Day 25:
- A CI assertion that every
generateStaticParamsroute appears insitemap.ts. Did not ship. But would have caught a different recent gap (Industry Insights). Still right; lower-priority than what actually came up. - Branded types for the dual slug system (
OperatorMasterSlugvsOperatorEntitySlug). Did not ship. Has been dormant; should pair with the next operator/insight schema change. - Coupling IndexNow + sitemap registration into one TypeScript step. Did not ship. When I added the videos category to IndexNow this week, it was still three separate commit edits (route file, sitemap, indexnow). The friction is real but bounded.
None of them got shipped because none of them was the most urgent thing. The actual most-urgent things were: agent platform refresh (PR #21 → #23), GSC video indexing bug (PR #27 → #28), and the latent Singapore address bug. Those are the right priorities. The three Day 24 targets roll forward to Day 26 or 27.
Tests, Lint, Build — Eight PRs Green
Throughout all of this: 287/287 tests pass, 0 lint errors, baseline 80 warnings unchanged, npx turbo build --filter=web succeeds 4/4 tasks on every PR. The one failure was the prod 500 on the per-video URLs after PR #27, which CI didn't catch because the issue manifests at request-time on a Vercel serverless runtime — not at build time on local Node. That's a different test gap. Day 26 task.
Eight PRs merged, one outage, ninety-minute hotfix, zero data loss. The agent platform is now visible across every public discovery surface with consistent counts (218 aircraft / 13 MCP tools / 14 Concierge tools / 16 CLI commands / 30 REST endpoints / 26K+ indexed URL candidates). The video centre indexes properly. The Speakable schema is on every article. The Singapore address is consistent across every JSON-LD on the site. The i18n coverage gap is documented and queued.
Five days, eight ships, one fire put out. Onto Day 26.
Frequently Asked Questions
What does Google's URL fragment deprecation for video sitemaps actually mean?+
Pre-2023 you could put multiple <video> entries in a sitemap, each scoped to /page#anchor1, /page#anchor2, etc. Google would treat each fragment as a unique landing surface. In 2023 they changed this: the <loc> URL is normalized by stripping the fragment for matching purposes. So /video-centre#video-a and /video-centre#video-b both reduce to /video-centre, and only one VideoObject schema can be paired with that URL. The reported error in Google Search Console is 'Video is not the main content of the page'. The fix is to give each video its own URL — in our case, /video-centre/[slug] as a dynamic route prerendered via generateStaticParams. There is no equivalent constraint for the regular (non-video) sitemap.
Why did /video-centre/[slug] crash with DYNAMIC_SERVER_USAGE despite being SSG?+
Next.js 16 with nested next-intl. Our route is /[locale]/video-centre/[slug]/page.tsx. The page declared generateStaticParams returning [{slug: 'x'}, ...] — just the slug, not the locale. At build time Next.js prerendered the /en/video-centre/x variant (because en is the default locale in next-intl's as-needed mode) but the locale param was treated as dynamic at request time for other locales. The nested ServerLocaleProvider layout then tried to read request-scoped state during render of a 'static' page → DYNAMIC_SERVER_USAGE. The fix is to enumerate { locale, slug } pairs in generateStaticParams (locales.flatMap(...) over the slug list) and set 'export const dynamicParams = false'. This is the same pattern /blog/[slug] uses; we just copied it wrong on the new route.
What was the Speakable schema, and why does it matter for an aviation site?+
Speakable is a schema.org annotation that tells voice assistants — Google Assistant, Alexa, and the next generation of LLM-driven voice search — which parts of a page are suitable for text-to-speech rendition. You add a 'speakable' field to a NewsArticle (or Article) JSON-LD pointing at CSS selectors. We added .speakable-headline (the H1) and .speakable-summary (the TL;DR paragraph) selectors and tagged the corresponding DOM elements on every blog post (95), monthly insight report (8), and industry insight page (103). For aviation specifically: 'Hey Siri, longest-range business jet?' becomes a query our pages are explicitly marked-up to answer. Zero implementation cost, opens an entire new surface.
Why was the Organization JSON-LD claiming New York when the press kit says Singapore?+
Legacy. The original Organization schema was written when the founding team was operating out of New York pre-incorporation. The Singapore legal entity stood up some months later, and every customer-facing surface was updated (footer, press kit, CLAUDE.md) — but the JSON-LD wasn't. We found it during a sweep grepping every PostalAddress in the codebase. This is the exact kind of inconsistency that poisons LLM training data: Google + Claude + GPT crawlers see two different HQ locations and lose confidence in both. Fixed in the same pass that added Twitter/X to sameAs, expanded availableLanguage from 4 to 11, added a 'sales' contactPoint pointing at charter@flyvolo.ai, and added alternateName: ['VOLO', 'flyvolo.ai', 'VOLO AI'] to help brand-mention match.
What were the three predictions from Day 24, and how did they pan out?+
Day 24 closed with three engineering targets: (1) Add a CI assertion that every generateStaticParams route appears in sitemap.ts. Status: not shipped. (2) Type the dual slug system with branded types — OperatorMasterSlug vs OperatorEntitySlug — so the compiler refuses to mix them. Status: not shipped. (3) Couple IndexNow + sitemap registration as one TypeScript step. Status: still three commits per route. Reflection: the week's real priorities were elsewhere — agent platform refresh, video indexing bug, i18n coverage audit. The three Day 24 targets are still right, just lower-priority than 'the CEO can see in GSC that we broke video indexing'. They roll over to Day 26.
What's the i18n coverage gap, and what's the plan to close it?+
We have 11-locale UI chrome — every label, button, FAQ heading, breadcrumb on ~25,500 page render combinations. But the content data itself is much thinner: 95 blog posts (43 have full ZH body, 54 only have ZH title+excerpt, 0 in other 9 locales), 91 destinations (only the name/country field is bilingual, descriptions are EN-only), 224 routes (entirely EN). A Japanese or French visitor lands on /ja/blog/world-cup-2026-los-angeles-...: UI renders in Japanese, title renders in Japanese, body is 100% English. The plan is two phases. Phase 1: build a Claude-powered translation pipeline (Sonnet 4.6 with prompt caching), backfill ZH for the 54 missing blog bodies + 91 destination descriptions. Phase 2: extend to ja/ko/fr/es/de/pt/ru/ar/zh-Hant. Documented and committed; not yet executed.
Prêt à voler ? Obtenez un devis charter personnalisé en quelques secondes.
Restez informé
Offres de vols à vide, nouvelles routes et analyses aviation — dans votre boîte mail.