From 3bfd20ee197af5a4fb755aea289b1d28e3956961 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:18:18 +0000 Subject: [PATCH] feat: add per-page SEO metadata, structured data, and AI-search crawler access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page except /stats and /courses previously fell back to the layout's generic title and description, so all 1,472 sitemap'd race pages looked like duplicates to search engines. - Add generateMetadata to race, result, athlete, and /races pages with unique titles, descriptions, and canonical URLs - Add metadataBase, a title template, OG siteName defaults, and Search Console verification (via GOOGLE_SITE_VERIFICATION env var) to the layout - Add SportsEvent JSON-LD to race pages and WebSite JSON-LD to the homepage - Unblock AI search / user-triggered crawlers (OAI-SearchBot, ChatGPT-User, PerplexityBot, Claude-Web) in robots.ts — they drive cited referrals and result pages no longer incur ISR write cost; training crawlers stay blocked - Retitle /stats and /courses to fit the new title template Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DM6tEZaDCx5PQgaxuV8Fh8 --- app/src/app/athlete/[slug]/page.tsx | 21 ++++++++++++ app/src/app/courses/page.tsx | 5 +-- app/src/app/layout.tsx | 18 +++++++++-- app/src/app/page.tsx | 13 ++++++++ app/src/app/race/[slug]/page.tsx | 34 ++++++++++++++++++++ app/src/app/race/[slug]/result/[id]/page.tsx | 21 ++++++++++++ app/src/app/races/page.tsx | 15 ++++++++- app/src/app/robots.ts | 15 +++++---- app/src/app/stats/page.tsx | 5 +-- 9 files changed, 134 insertions(+), 13 deletions(-) diff --git a/app/src/app/athlete/[slug]/page.tsx b/app/src/app/athlete/[slug]/page.tsx index 2fc16b0..c29dbf2 100644 --- a/app/src/app/athlete/[slug]/page.tsx +++ b/app/src/app/athlete/[slug]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getAthleteProfile } from "@/lib/athlete-shards"; import { getCountryFlagISO } from "@/lib/flags"; @@ -16,6 +17,26 @@ interface PageProps { params: Promise<{ slug: string }>; } +// The duplicate getAthleteProfile call (metadata + page) is served from the +// in-memory shard cache, so it costs one shard fetch, not two. +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + const profile = await getAthleteProfile(slug); + if (!profile) return {}; + + const name = formatAthleteName(profile.fullName); + const raceCount = profile.races.length; + const title = `${name} — Triathlon Race History`; + const description = `${name}${profile.country ? ` (${profile.country})` : ""} has ${raceCount} IRONMAN and IRONMAN 70.3 ${raceCount === 1 ? "result" : "results"} on TriTimes. View finish times, splits, and percentiles for every race.`; + + return { + title, + description, + alternates: { canonical: `/athlete/${slug}` }, + openGraph: { title, description, url: `/athlete/${slug}` }, + }; +} + export default async function AthletePage({ params }: PageProps) { const { slug } = await params; const profile = await getAthleteProfile(slug); diff --git a/app/src/app/courses/page.tsx b/app/src/app/courses/page.tsx index 406f53b..48eabe6 100644 --- a/app/src/app/courses/page.tsx +++ b/app/src/app/courses/page.tsx @@ -2,9 +2,10 @@ import { getCourseStats } from "@/lib/data"; import CourseCharts from "./course-charts"; export const metadata = { - title: "Course Difficulty | TriTimes", + title: "IRONMAN & 70.3 Course Difficulty", description: - "Compare IRONMAN and IRONMAN 70.3 course difficulty based on median finish times across all race editions.", + "Which IRONMAN courses are fastest? Compare IRONMAN and IRONMAN 70.3 course difficulty based on median finish times across all race editions.", + alternates: { canonical: "/courses" }, }; export default function CoursesPage() { diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 2962e9f..b0acba0 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -18,9 +18,23 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "TriTimes — Triathlon Times", + metadataBase: new URL("https://tritimes.org"), + title: { + default: "TriTimes — IRONMAN & 70.3 Triathlon Results", + template: "%s | TriTimes", + }, description: - "View your IronMan 70.3 triathlon results with statistical distributions for swim, bike, run, and total time.", + "Look up IRONMAN and IRONMAN 70.3 race results with full-field time distributions. See your percentile for swim, bike, run, and overall across 1,400+ races since 2002.", + openGraph: { + siteName: "TriTimes", + type: "website", + url: "https://tritimes.org", + }, + // Set GOOGLE_SITE_VERIFICATION in Vercel to verify the site in Google + // Search Console without committing the token. + verification: { + google: process.env.GOOGLE_SITE_VERIFICATION, + }, }; export default function RootLayout({ diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 0f96515..a0ee951 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -1,8 +1,21 @@ import GlobalSearchBar from "@/components/GlobalSearchBar"; +const WEBSITE_JSON_LD = { + "@context": "https://schema.org", + "@type": "WebSite", + name: "TriTimes", + url: "https://tritimes.org", + description: + "IRONMAN and IRONMAN 70.3 race results with full-field time distributions and percentiles for swim, bike, and run.", +}; + export default function Home() { return (
+