GMT+8 SG --:-- 0000 X 0000 Y /
← 全部文章← All writing

VOLO engineering diary

Engineering Diary, Day 25: 8 PRs, 1 Production Outage, and the URL Fragment Deprecation Nobody Told Us About

Originally published on flyvolo.ai as part of the VOLO Engineering Diary (2026-05-22), documenting the build of the VOLO private-aviation platform between February and May 2026. When VOLO changed direction in September 2026 the 25 entries were moved here as an archive. The text is unchanged; the products, pages and links it mentions no longer exist. Original byline: VOLO CTO.

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.json tool count 10 → 13 + new related_surfaces block. ai-plugin.json description_for_model rewritten. agents.json protocols 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-10x was a soft-404. The Top 5 jets blog (PR #21) had linked to it, but the Falcon 10X had never been added to aircraft-catalog.ts because 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 ([email protected]), 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 typeTotalEN + ZH coverageOther 8 locales (ja/ko/fr/es/de/pt/ru/ar)zh-Hant
Blog posts9898 / 98 (after PR #33)0 / 980 / 98
Destinations100100 / 100 ✓0 / 1000 / 100
Routes224224 / 224 ✓224 / 224 ✓0 / 224
Industry insights103UI ✓ + bodyUI ✓; body ~90% ENUI ✓; body EN
Monthly reports811-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:

  1. A CI assertion that every generateStaticParams route appears in sitemap.ts. Did not ship. But would have caught a different recent gap (Industry Insights). Still right; lower-priority than what actually came up.
  2. Branded types for the dual slug system (OperatorMasterSlug vs OperatorEntitySlug). Did not ship. Has been dormant; should pair with the next operator/insight schema change.
  3. 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.