Engineering Diary, Day 11: Google Said No — SEO Triage Across 1,700 Pages
The Dashboard of Pain
When you launch a multilingual website with 3,400+ URLs and then check Google Search Console a week later, you should be prepared for bad news. We were not prepared.
Six error categories stared back at us, each one a different flavor of "Google cannot or will not index your pages":
- Server error (5xx) — 100+ URLs crashing on render
- Alternate page with proper canonical tag — 118 pages with broken canonical references
- Duplicate without user-selected canonical — 2 pages
- Not found (404) — 1 legacy URL
- Discovered — currently not indexed — 1,720 pages Google found but had not visited
- Crawled — currently not indexed — 61 pages Google visited and explicitly rejected
Each category had a different root cause. Each required a different fix. This is the story of diagnosing and resolving all six in one session.
Bug 1: The 5xx Massacre
Over 100 URLs returning server errors. The pattern was immediately obvious: every non-English locale variant of our dynamic pages was crashing. /fr/operators/netjets — 500. /zh/airports/teterboro — 500. /es/case-studies/diplomatic-charter — 500. The English versions worked fine.
Root cause: generateStaticParams() in six page files was returning only { slug } without { locale, slug }. In Next.js App Router, when your page lives under [locale]/[slug], the static params must include both dynamic segments. Without locale, Next.js only pre-renders the default locale variant. Any other locale hits the server at runtime, and because these are static-data pages with no server-side fallback, they crash.
The fix was mechanical but had to be applied precisely across six files — operators, empty-legs, airports, airport FBOs, individual FBO detail, and case studies:
// Before — only generates English variants
export async function generateStaticParams() {
return getAllSlugs().map((slug) => ({ slug }));
}
// After — generates all 4 locale × slug combinations
export async function generateStaticParams() {
const slugs = getAllSlugs();
const locales = ["en", "zh", "fr", "es"];
return locales.flatMap((locale) =>
slugs.map((slug) => ({ locale, slug })),
);
}
Six files changed. Approximately 1,065 URLs restored from 5xx to 200. The most impactful single fix of the session.
Bug 2: The Canonical Betrayal
118 pages flagged as "Alternate page with proper canonical tag." This means Google found the page, saw its canonical URL pointing somewhere else, and decided to index the canonical target instead. Our French, Chinese, and Spanish pages were all pointing their canonical to the English version.
The problem was in our shared SEO utility function buildAlternates(). It always generated the canonical as the bare English URL, regardless of which locale was rendering:
// Before — always canonical to English
export function buildAlternates(path: string) {
return {
canonical: `${BASE_URL}${path}`,
languages: { en: ..., zh: ..., fr: ..., es: ... },
};
}
The correct behavior: each locale variant should self-reference. /fr/fleet/citation-x should have canonical /fr/fleet/citation-x, not /fleet/citation-x. This is how hreflang is supposed to work — each page is canonical to itself, and the hreflang annotations tell Google they are related variants.
// After — self-referencing canonical per locale
export function buildAlternates(path: string, locale?: string) {
const prefix = locale && locale !== "en" ? `/${locale}` : "";
return {
canonical: `${BASE_URL}${prefix}${path}`,
languages: { en: ..., zh: ..., fr: ..., es: ... },
};
}
But fixing the utility was only half the battle. Every page that calls buildAlternates() needed to pass the locale parameter. And worse: 34 pages used export const metadata — a static export that cannot access route params. Each of those 34 pages had to be converted from static metadata to a dynamic generateMetadata() function that extracts locale from params.
53 files changed in a single commit. The kind of sweeping change that makes you grateful for TypeScript — if the type system compiles, the pages will render.
Bug 3: The Ghost URL
One 404: /quote. We had renamed the page to /contact weeks ago but never set up a redirect. Anyone who bookmarked the old URL or found it in a cached search result hit a dead end. One line in next.config.ts:
redirects: async () => [
{ source: "/quote", destination: "/contact", permanent: true }
],
A 301 redirect tells Google to transfer all ranking signals from the old URL to the new one. Forgetting this is one of the most common SEO mistakes in website redesigns.
Bug 4: 1,720 Undiscovered Pages
"Discovered — currently not indexed" means Google found the URLs (in our sitemap or through internal links) but has not yet sent a crawler to visit them. With 3,428 URLs in our sitemap, this is partly normal — Google does not crawl everything immediately.
Two actions. First, we submitted all 3,428 URLs via the IndexNow protocol, which pings Bing, Yandex, Seznam, and Naver simultaneously. Google does not directly support IndexNow, but benefits from Bing's data sharing. Second, we analyzed our internal linking structure and found that key hub pages — /airports, /operators, /case-studies — were linked in the footer but missing from the header navigation.
The header is the single most important internal linking element on any website. Every page on the site renders the header, which means every link in it gets maximum crawl priority. We restructured the header navigation: Destinations became a dropdown containing the Destinations hub plus Airports, Operators, and Case Studies. The About dropdown gained a link to our For Agents page. Four translation files updated across English, Chinese, French, and Spanish.
Bug 5: The Thin Content Rejection
"Crawled — currently not indexed" is the most concerning status in Search Console. It means Google actually visited the page, evaluated the content, and decided it was not worth indexing. Unlike "Discovered" (where Google has not visited yet), this is an active rejection.
61 pages rejected. The pattern: mostly fleet catalog pages and their locale variants. /es/fleet/learjet-70. /fleet/citation-cj4. /fleet/gulfstream-g300.
The diagnosis: our fleet section has two tiers of aircraft data. 15 aircraft have rich, detailed content — 400+ word bilingual descriptions, cabin layouts, performance specs, ideal routes, AI match notes. But 184 aircraft are catalog-only entries with minimal data. These catalog pages rendered using a shared CatalogDetailPage component that displayed the same category-level description for every aircraft in the same class. All 40+ light jets shared identical paragraphs. All midsize jets shared identical paragraphs. Google correctly identified this as thin, duplicative content.
The fix: we built a generateAircraftIntro() function that creates a unique introductory paragraph for each aircraft based on its specific specifications — manufacturer, range classification, cabin dimensions, cruise speed tier, WiFi availability, and pricing. The function uses conditional logic to produce genuinely different text for each model:
- Range descriptor: "ultra-long-range global" vs "medium-range regional" vs "short-range regional" based on nautical mile thresholds
- Cabin descriptor: "full stand-up cabin" vs "compact cabin" based on ceiling height
- Speed tier: "high-speed cruise capability" vs "efficient cruise speed" based on knot thresholds
- Specific dimensions and volume calculations unique to each airframe
Each of the 184 catalog pages now opens with a paragraph that is unique to that specific aircraft, followed by the category-level content. The combination pushes each page above the thin content threshold with differentiated, factual prose.
Bug 6: The Build Time Regression
After fixing the generateStaticParams for all locale variants, we introduced a new problem: build time went from 2 minutes to 12 minutes on Vercel. The math is straightforward — we went from pre-rendering ~199 fleet pages (English-only slugs) to 796 pages (199 aircraft × 4 locales).
The solution: only pre-render what needs pre-rendering. The 15 detailed aircraft pages have rich content that benefits from static generation. The 184 catalog pages are lighter and can be rendered on-demand. Next.js dynamicParams = true was already configured, so catalog pages render on first visit and are ISR-cached automatically. We changed generateStaticParams to return only the 15 detailed aircraft × 4 locales = 60 pages instead of 796.
Build time dropped back to a reasonable range. Vercel deployments stopped queuing behind 12-minute builds.
What Shipped Today
| Commit | Fix | Files Changed | Pages Affected |
|---|---|---|---|
7f6b740 | Add locale to generateStaticParams for 6 page types | 6 | ~1,065 URLs restored from 5xx |
df49944 | Self-referencing canonical URLs for all locale variants | 53 | 118+ pages with correct canonicals |
9737f12 | 301 redirect /quote → /contact | 1 | 1 legacy URL preserved |
a70b825 | Add airports/operators/case-studies to Header nav | 5 | All pages (improved internal linking) |
f39b583 | Unique per-aircraft descriptions for 184 catalog pages | 1 | 736 pages (184 × 4 locales) |
2528dc2 | Pre-render only 15 detailed aircraft at build time | 1 | Build time: 12min → ~5min |
| Total | 67 files | ~3,400 URLs affected | |
Plus: 3,428 URLs submitted via IndexNow for accelerated recrawling.
Lessons
Internationalization is not just translation. Adding next-intl and translating strings is step one. Step two is making sure every piece of your SEO infrastructure — static params, canonical URLs, metadata functions — is locale-aware. We had the translations but forgot to teach the framework about them.
Static metadata is a trap. In Next.js App Router, export const metadata looks clean and simple. But the moment your page lives under a dynamic segment like [locale], you need runtime access to route params, which means you need generateMetadata(). We converted 34 pages in one pass. The lesson: start with dynamic metadata from day one if you are building a multilingual site.
Thin content is a real signal. Google did not reject our catalog pages because of a technical error. It rejected them because the content was genuinely thin — identical paragraphs across 40+ pages in the same category. The fix was not a meta tag or a configuration change. It was writing better content. The technical solution (a spec-based paragraph generator) is pragmatic, but the underlying principle is simple: every indexed page needs to justify its existence with unique, substantive content.
Build time is a feature. Going from 2-minute deploys to 12-minute deploys is not just an inconvenience — it changes how you ship. Rapid iteration depends on fast feedback loops. When a deploy takes 12 minutes, you batch changes. When it takes 2 minutes, you ship incrementally. We chose to pre-render less and cache on-demand, trading first-visit latency on catalog pages for a 60% reduction in build time.
SEO is not a feature you ship once. It is infrastructure that breaks in proportion to the ambition of your site. A 10-page marketing site needs none of this. A 3,400-page multilingual platform with dynamic routes, four locales, and 199 aircraft? Every assumption about static generation, canonical URLs, and content uniqueness gets stress-tested at scale. Today we passed the test — but only because Google told us we were failing.
Stay Informed
Empty leg deals, new routes, and aviation insights — delivered to your inbox.